From 7c1cbb26000539537a6a6811662e4bfd0c13d844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:20:24 +0300 Subject: [PATCH 01/50] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 38 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From a9d7d08f989a7cbd6d4e181f2fb2190e0d5ac516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:24:36 +0300 Subject: [PATCH 02/50] Update bug report template for clarity and structure --- .github/ISSUE_TEMPLATE/bug_report.md | 57 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea78..9b9a7537 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,38 +1,39 @@ --- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - +name: "🐞 Bug Report" +about: Report a reproducible issue to help us improve UltimateAuth +title: "[Bug]: " +labels: bug +assignees: "" --- -**Describe the bug** -A clear and concise description of what the bug is. +## 🐞 Bug Description +A clear and concise description of the issue. + + -**To Reproduce** -Steps to reproduce the behavior: +## 📌 Steps to Reproduce 1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +2. Call method '...' +3. Observe behavior '...' + + + +## 🧪 Expected Behavior +What should have happened? + + +## 📷 Screenshots / Logs (if applicable) +Paste stack traces, console logs, or screenshots. + -**Expected behavior** -A clear and concise description of what you expected to happen. -**Screenshots** -If applicable, add screenshots to help explain your problem. +## 🧩 Environment +- UltimateAuth version: +- .NET version: +- Platform: (Blazor / MAUI / ASP.NET Core / Other) +- OS: -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] -**Additional context** -Add any other context about the problem here. +## ✔ Additional Context +Add any other relevant context about the problem here. From 93e6fb65f4f3994ca9ed5a05da610a456119bc2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:26:09 +0300 Subject: [PATCH 03/50] Revise feature request template for clarity and structure Updated the feature request template to include new sections and improved formatting. --- .github/ISSUE_TEMPLATE/feature_request.md | 39 +++++++++++++++-------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7d..7a4cdb6c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,20 +1,33 @@ --- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' +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. --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +## 🛠 Suggested Implementation +If you have any implementation ideas, describe them: +- Proposed API shape +- Example usage +- Integration points + +--- -**Describe the solution you'd like** -A clear and concise description of what you want to happen. +## 🔗 Related Issues / Discussions +(Optional) Link any related issues or design proposals. -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +--- -**Additional context** -Add any other context or screenshots about the feature request here. +## ✔ Additional Notes +Anything else we should know? From 76c81272b6d35b48079f7816aa0586401115d689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:27:17 +0300 Subject: [PATCH 04/50] Add design proposal issue template --- .github/ISSUE_TEMPLATE/design_proposal.md | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/design_proposal.md 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. From eca445de4d5ce9afbd78f3ca156cda424eeb4941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:38:42 +0300 Subject: [PATCH 05/50] Update GitHub Sponsors username in FUNDING.yml --- .github/FUNDING.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/FUNDING.yml 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'] From 4e12f049e139ddf5d6c029d70fc866371a90d2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:59:41 +0300 Subject: [PATCH 06/50] Create pull request template for contributions Added a pull request template to guide contributors. --- .github/PULL_REQUEST_TEMPLATE.md | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md 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 From 70bebb681e042bfbd0d872bc0a0c08f3768a8756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 11 Dec 2025 00:10:22 +0300 Subject: [PATCH 07/50] First Implementation of Core Project (#2) * First Implementation of Core Project * Extensions & Options & Validators * Errors & Utilities & Multi Tenancy Support & Events * Fixes * Add XML Definitions --- .../Abstractions/.gitkeep | 1 - .../Abstractions/IUser.cs | 21 +++ .../Abstractions/IUserIdConverter.cs | 45 +++++ .../Abstractions/IUserIdConverterResolver.cs | 23 +++ .../Abstractions/Services/ISessionService.cs | 93 ++++++++++ .../Stores/DefaultSessionStoreFactory.cs | 21 +++ .../Abstractions/Stores/ISessionStore.cs | 140 +++++++++++++++ .../Stores/ISessionStoreFactory.cs | 25 +++ .../Abstractions/Stores/IUserStore.cs | 58 ++++++ .../Abstractions/Stores/IUserStoreFactory.cs | 26 +++ .../CodeBeam.UltimateAuth.Core.csproj | 6 + .../Domain/Session/AuthSessionId.cs | 61 +++++++ .../Domain/Session/ChainId.cs | 62 +++++++ .../Domain/Session/DeviceInfo.cs | 60 +++++++ .../Domain/Session/ISession.cs | 73 ++++++++ .../Domain/Session/ISessionChain.cs | 59 ++++++ .../Domain/Session/ISessionRoot.cs | 54 ++++++ .../Domain/Session/SessionMetadata.cs | 40 +++++ .../Domain/Session/SessionState.cs | 44 +++++ .../Errors/Base/UAuthDeveloperException.cs | 18 ++ .../Errors/Base/UAuthDomainException.cs | 16 ++ .../Errors/Base/UAuthException.cs | 25 +++ .../Errors/Base/UAuthSessionException.cs | 29 +++ .../Errors/Developer/UAuthConfigException.cs | 18 ++ .../Developer/UAuthInternalException.cs | 20 +++ .../Errors/Developer/UAuthStoreException.cs | 18 ++ .../Errors/UAuthDeviceLimitException.cs | 25 +++ .../UAuthInvalidCredentialsException.cs | 18 ++ .../Errors/UAuthInvalidPkceCodeException.cs | 20 +++ .../Errors/UAuthRootRevokedException.cs | 20 +++ .../UAuthSecurityVersionMismatchException.cs | 37 ++++ .../Errors/UAuthSessionExpiredException.cs | 26 +++ .../Errors/UAuthSessionNotActiveException.cs | 25 +++ .../Errors/UAuthSessionRevokedException.cs | 27 +++ .../Errors/UAuthTokenTamperedException.cs | 26 +++ .../Events/IAuthEventContext.cs | 7 + .../Events/SessionCreatedContext.cs | 48 +++++ .../Events/SessionRefreshedContext.cs | 60 +++++++ .../Events/SessionRevokedContext.cs | 57 ++++++ .../Events/UAuthEventDispatcher.cs | 53 ++++++ .../Events/UAuthEvents.cs | 57 ++++++ .../Events/UserLoggedInContext.cs | 42 +++++ .../Events/UserLoggedOutContext.cs | 41 +++++ .../Extensions/.gitkeep | 1 - ...UltimateAuthServiceCollectionExtensions.cs | 93 ++++++++++ .../UltimateAuthSessionStoreExtensions.cs | 102 +++++++++++ .../UserIdConverterRegistrationExtensions.cs | 64 +++++++ .../Internal/UAuthSession.cs | 71 ++++++++ .../Internal/UAuthSessionChain.cs | 76 ++++++++ .../Internal/UAuthSessionRoot.cs | 95 ++++++++++ .../Internal/UAuthSessionService.cs | 170 ++++++++++++++++++ .../Models/.gitkeep | 1 - .../Models/SessionResult.cs | 40 +++++ .../Models/SessionValidationResult.cs | 35 ++++ .../MultiTenancy/CompositeTenantResolver.cs | 37 ++++ .../MultiTenancy/FixedTenantResolver.cs | 27 +++ .../MultiTenancy/HeaderTenantResolver.cs | 38 ++++ .../MultiTenancy/HostTenantResolver.cs | 34 ++++ .../MultiTenancy/ITenantResolver.cs | 16 ++ .../MultiTenancy/PathTenantResolver.cs | 40 +++++ .../MultiTenancy/TenantResolutionContext.cs | 37 ++++ .../Options/.gitkeep | 1 - .../Options/LoginOptions.cs | 21 +++ .../Options/MultiTenantOptions.cs | 59 ++++++ .../Options/MultiTenantOptionsValidator.cs | 85 +++++++++ .../Options/PkceOptions.cs | 17 ++ .../Options/PkceOptionsValidator.cs | 21 +++ .../Options/SessionOptions.cs | 80 +++++++++ .../Options/SessionOptionsValidator.cs | 100 +++++++++++ .../Options/TokenOptions.cs | 57 ++++++ .../Options/TokenOptionsValidator.cs | 52 ++++++ .../Options/UltimateAuthOptions.cs | 58 ++++++ .../Options/UltimateAuthOptionsValidator.cs | 44 +++++ .../Security/.gitkeep | 1 - .../Utilities/Base64Url.cs | 49 +++++ .../Utilities/RandomIdGenerator.cs | 54 ++++++ .../Utilities/UAuthUserIdConverter.cs | 83 +++++++++ .../Utilities/UAuthUserIdConverterResolver.cs | 47 +++++ 78 files changed, 3446 insertions(+), 5 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverterResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionExpiredException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionNotActiveException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionRevokedException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Internal/UAuthSession.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionChain.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionRoot.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Models/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Options/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/LoginOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/PkceOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/PkceOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/SessionOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/SessionOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/TokenOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/TokenOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptionsValidator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Security/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Utilities/RandomIdGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs 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/IUser.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs new file mode 100644 index 00000000..b3fe9709 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs @@ -0,0 +1,21 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// 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 IUser + { + /// + /// 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/Abstractions/IUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs new file mode 100644 index 00000000..a52f3ec9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs @@ -0,0 +1,45 @@ +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. + /// + 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 ToString(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); + + /// + /// 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); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverterResolver.cs new file mode 100644 index 00000000..6258bb6f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverterResolver.cs @@ -0,0 +1,23 @@ +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(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs new file mode 100644 index 00000000..c9ba2157 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Models; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation. + /// + /// The type used to uniquely identify the user. + public interface ISessionService + { + /// + /// Creates a new login session for the specified user. + /// + /// + /// The tenant identifier. Use null for single-tenant applications. + /// + /// The user associated with the session. + /// Information about the device initiating the session. + /// Optional metadata describing the session context. + /// The current UTC timestamp. + /// + /// A result containing the newly created session, chain, and session root. + /// + Task> CreateLoginSessionAsync(string? tenantId, TUserId userId, DeviceInfo deviceInfo, SessionMetadata? metadata, DateTime now); + + /// + /// Rotates the specified session and issues a new one while preserving the session chain. + /// + /// The tenant identifier, or null. + /// The active session identifier to be refreshed. + /// The current UTC timestamp. + /// + /// A result containing the refreshed session and updated chain. + /// + /// + /// Thrown if the session, its chain, or the user's session root is invalid. + /// + Task> RefreshSessionAsync(string? tenantId, AuthSessionId currentSessionId,DateTime now); + + /// + /// Revokes a single session, preventing further use. + /// + /// The tenant identifier, or null. + /// The session identifier to revoke. + /// The UTC timestamp of the revocation. + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + + /// + /// Revokes an entire session chain (device-level logout). + /// + /// The tenant identifier, or null. + /// The session chain identifier to revoke. + /// The UTC timestamp of the revocation. + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); + + /// + /// Revokes the user's session root, invalidating all existing sessions across all chains. + /// + /// The tenant identifier, or null. + /// The user whose root should be revoked. + /// The UTC timestamp of the revocation. + Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at); + + /// + /// Validates a session and evaluates its current state, including expiration, revocation, and security version alignment. + /// + /// The tenant identifier, or null. + /// The session identifier to validate. + /// The current UTC timestamp. + /// + /// A detailed validation result describing the session, chain, root, + /// and computed session state. + /// + Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime now); + + /// + /// Retrieves all session chains belonging to the specified user. + /// + /// The tenant identifier, or null. + /// The user whose session chains are requested. + /// A read-only list of session chains. + Task>> GetChainsAsync(string? tenantId, TUserId userId); + + /// + /// Retrieves all sessions belonging to a specific session chain. + /// + /// The tenant identifier, or null. + /// The session chain identifier. + /// A read-only list of sessions contained within the chain. + Task>> GetSessionsAsync(string? tenantId, ChainId chainId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs new file mode 100644 index 00000000..8ff089de --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs @@ -0,0 +1,21 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Default session store factory that throws until a real store implementation is registered. + /// + public sealed class DefaultSessionStoreFactory : ISessionStoreFactory + { + /// Creates a session store instance for the given user ID type, but always throws because no store has been registered. + /// The tenant identifier, or null in single-tenant mode. + /// The type used to uniquely identify the user. + /// Never returns; always throws. + /// Thrown when no session store implementation has been configured. + public ISessionStore Create(string? tenantId) + { + throw new InvalidOperationException( + "No ISessionStore implementation registered. " + + "Call AddUltimateAuthServer().AddSessionStore() to provide a real implementation." + ); + } + } +} 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..3729d719 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -0,0 +1,140 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Defines the low-level persistence operations for sessions, session chains, and session roots in a multi-tenant or single-tenant environment. + /// Store implementations provide durable and atomic data access. + /// + public interface ISessionStore + { + /// + /// Retrieves a session by its identifier within the given tenant context. + /// + /// The tenant identifier, or null for single-tenant mode. + /// The session identifier. + /// The session instance or null if not found. + Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId); + + /// + /// Persists a new session or updates an existing one within the tenant scope. + /// Implementations must ensure atomic writes. + /// + /// The tenant identifier, or null. + /// The session to persist. + Task SaveSessionAsync(string? tenantId, ISession session); + + /// + /// Marks the specified session as revoked, preventing future authentication. + /// Revocation timestamp must be stored reliably. + /// + /// The tenant identifier, or null. + /// The session identifier. + /// The UTC timestamp of revocation. + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + + /// + /// Returns all sessions belonging to the specified chain, ordered according to store implementation rules. + /// + /// The tenant identifier, or null. + /// The chain identifier. + /// A read-only list of sessions. + Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId); + + /// + /// Retrieves a session chain by identifier. Returns null if the chain does not exist in the provided tenant context. + /// + /// The tenant identifier, or null. + /// The chain identifier. + /// The chain or null. + Task?> GetChainAsync(string? tenantId, ChainId chainId); + + /// + /// Inserts a new session chain into the store. Implementations must ensure consistency with the related sessions and session root. + /// + /// The tenant identifier, or null. + /// The chain to save. + Task SaveChainAsync(string? tenantId, ISessionChain chain); + + /// + /// Updates an existing session chain, typically after session rotation or revocation. Implementations must preserve atomicity. + /// + /// The tenant identifier, or null. + /// The updated session chain. + Task UpdateChainAsync(string? tenantId, ISessionChain chain); + + /// + /// Marks the entire session chain as revoked, invalidating all associated sessions for the device or app family. + /// + /// The tenant identifier, or null. + /// The chain to revoke. + /// The UTC timestamp of revocation. + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); + + /// + /// Retrieves the active session identifier for the specified chain. + /// This is typically an O(1) lookup and used for session rotation. + /// + /// The tenant identifier, or null. + /// The chain whose active session is requested. + /// The active session identifier or null. + Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId); + + /// + /// Sets or replaces the active session identifier for the specified chain. + /// Must be atomic to prevent race conditions during refresh. + /// + /// The tenant identifier, or null. + /// The chain whose active session is being set. + /// The new active session identifier. + Task SetActiveSessionIdAsync(string? tenantId, ChainId chainId, AuthSessionId sessionId); + + /// + /// Retrieves all session chains belonging to the specified user within the tenant scope. + /// + /// The tenant identifier, or null. + /// The user whose chains are being retrieved. + /// A read-only list of session chains. + Task>> GetChainsByUserAsync(string? tenantId, TUserId userId); + + /// + /// Retrieves the session root for the user, which represents the full set of chains and their associated security metadata. + /// Returns null if the root does not exist. + /// + /// The tenant identifier, or null. + /// The user identifier. + /// The session root or null. + Task?> GetSessionRootAsync(string? tenantId, TUserId userId); + + /// + /// Persists a session root structure, usually after chain creation, rotation, or security operations. + /// + /// The tenant identifier, or null. + /// The session root to save. + Task SaveSessionRootAsync(string? tenantId, ISessionRoot root); + + /// + /// Revokes the session root, invalidating all chains and sessions belonging to the specified user in the tenant scope. + /// + /// The tenant identifier, or null. + /// The user whose root should be revoked. + /// The UTC timestamp of revocation. + Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTime at); + + /// + /// Removes expired sessions from the store while leaving chains and session roots intact. Cleanup strategy is determined by the store implementation. + /// + /// The tenant identifier, or null. + /// The current UTC timestamp. + Task DeleteExpiredSessionsAsync(string? tenantId, DateTime now); + + /// + /// Retrieves the chain identifier associated with the specified session. + /// + /// The tenant identifier, or null. + /// The session identifier. + /// The chain identifier or null. + Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId); + } + +} 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..0a18fe12 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs @@ -0,0 +1,25 @@ +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. + /// + /// The type used to uniquely identify users. + /// + /// The tenant identifier for multi-tenant environments, or null for single-tenant mode. + /// + /// + /// An implementation able to perform session persistence operations. + /// + /// + /// Thrown if no compatible session store implementation is registered. + /// + ISessionStore Create(string? tenantId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs new file mode 100644 index 00000000..fcb268b1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs @@ -0,0 +1,58 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Provides minimal user lookup and security metadata required for authentication. + /// This store does not manage user creation, claims, or profile data — these belong + /// to higher-level application services outside UltimateAuth. + /// + public interface IUserStore + { + /// + /// Retrieves a user by identifier. Returns null if no such user exists. + /// + /// The identifier of the user. + /// The user instance or null if not found. + Task?> FindByIdAsync(TUserId userId); + + /// + /// Retrieves a user by a login credential such as username or email. + /// Returns null if no matching user exists. + /// + /// The login value used to locate the user. + /// The user instance or null if not found. + Task?> FindByLoginAsync(string login); + + /// + /// Returns the password hash for the specified user, if the user participates + /// in password-based authentication. Returns null for passwordless users + /// (e.g., external login or passkey-only accounts). + /// + /// The user identifier. + /// The password hash or null. + Task GetPasswordHashAsync(TUserId userId); + + /// + /// Updates the password hash for the specified user. This method is invoked by + /// password management services and not by . + /// + /// The user identifier. + /// The new password hash value. + Task SetPasswordHashAsync(TUserId userId, string passwordHash); + + /// + /// Retrieves the security version associated with the user. + /// This value increments whenever critical security actions occur, such as: + /// password reset, MFA reset, external login removal, or account recovery. + /// + /// The user identifier. + /// The current security version. + Task GetSecurityVersionAsync(TUserId userId); + + /// + /// Increments the user's security version, invalidating all existing sessions. + /// This is typically called after sensitive security events occur. + /// + /// The user identifier. + Task IncrementSecurityVersionAsync(TUserId userId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs new file mode 100644 index 00000000..06e7a0d7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs @@ -0,0 +1,26 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Provides a factory abstraction for creating tenant-scoped user store + /// instances used for retrieving basic user information required by + /// UltimateAuth authentication services. + /// + public interface IUserStoreFactory + { + /// + /// Creates and returns a user store instance for the specified user ID type within the given tenant context. + /// + /// The type used to uniquely identify users. + /// + /// The tenant identifier for multi-tenant environments, or null + /// in single-tenant deployments. + /// + /// + /// An implementation capable of user lookup and security metadata retrieval. + /// + /// + /// Thrown if no user store implementation has been registered for the given user ID type. + /// + IUserStore Create(string tenantId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj index 8004a0dd..34c42190 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -7,4 +7,10 @@ true + + + + + + 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..f5a8ba2e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -0,0 +1,61 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Represents a strongly typed identifier for an authentication session. + /// Wraps a value and provides type safety across the UltimateAuth session management system. + /// + public readonly struct AuthSessionId : IEquatable + { + /// + /// Initializes a new using the specified GUID value. + /// + /// The underlying GUID representing the session identifier. + public AuthSessionId(Guid value) + { + Value = value; + } + + /// + /// Gets the underlying GUID value of the session identifier. + /// + public Guid Value { get; } + + /// + /// Generates a new session identifier using a newly created GUID. + /// + /// A new instance. + public static AuthSessionId New() => new AuthSessionId(Guid.NewGuid()); + + /// + /// Determines whether the specified is equal to the current instance. + /// + /// The session identifier to compare with. + /// true if the identifiers match; otherwise, false. + public bool Equals(AuthSessionId other) => Value.Equals(other.Value); + + /// + /// Determines whether the specified object is equal to the current session identifier. + /// + /// The object to compare with. + /// true if the object is an with the same value. + public override bool Equals(object? obj) => obj is AuthSessionId other && Equals(other); + + /// + /// Returns a hash code based on the underlying GUID value. + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + /// Returns the string representation of the underlying GUID value. + /// + /// The GUID as a string. + public override string ToString() => Value.ToString(); + + /// + /// Converts the to its underlying . + /// + /// The session identifier. + /// The underlying GUID value. + public static implicit operator Guid(AuthSessionId id) => id.Value; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs new file mode 100644 index 00000000..967032b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs @@ -0,0 +1,62 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Represents a strongly typed identifier for a session chain. + /// A session chain groups multiple rotated sessions belonging to the same + /// device or application family, providing type safety across the UltimateAuth session system. + /// + public readonly struct ChainId : IEquatable + { + /// + /// Initializes a new with the specified GUID value. + /// + /// The underlying GUID representing the chain identifier. + public ChainId(Guid value) + { + Value = value; + } + + /// + /// Gets the underlying GUID value of the chain identifier. + /// + public Guid Value { get; } + + /// + /// Generates a new chain identifier using a newly created GUID. + /// + /// A new instance. + public static ChainId New() => new ChainId(Guid.NewGuid()); + + /// + /// Determines whether the specified is equal to the current instance. + /// + /// The chain identifier to compare with. + /// true if both identifiers represent the same chain. + public bool Equals(ChainId other) => Value.Equals(other.Value); + + /// + /// Determines whether the specified object is equal to the current chain identifier. + /// + /// The object to compare with. + /// true if the object is a with the same value. + public override bool Equals(object? obj) => obj is ChainId other && Equals(other); + + /// + /// Returns a hash code based on the underlying GUID value. + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + /// Returns the string representation of the underlying GUID value. + /// + /// The GUID as a string. + public override string ToString() => Value.ToString(); + + /// + /// Converts the to its underlying value. + /// + /// The chain identifier. + /// The underlying GUID value. + public static implicit operator Guid(ChainId id) => id.Value; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs new file mode 100644 index 00000000..ca204c3a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs @@ -0,0 +1,60 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Represents metadata describing the device or client environment initiating + /// an authentication session. Used for security analytics, session management, + /// fraud detection, and device-specific login policies. + /// + public sealed class DeviceInfo + { + /// + /// Gets the high-level platform identifier, such as web, mobile, + /// tablet or iot. + /// Used for platform-based session limits and analytics. + /// + public string? Platform { get; init; } + + /// + /// Gets the operating system of the client device, such as iOS 17, + /// Android 14, Windows 11, or macOS Sonoma. + /// + public string? OperatingSystem { get; init; } + + /// + /// Gets the browser name and version when the client is web-based, + /// such as Edge, Chrome, Safari, or Firefox. + /// May be null for native applications. + /// + public string? Browser { get; init; } + + /// + /// Gets the IP address of the client device. + /// Used for IP-binding, geolocation checks, and anomaly detection. + /// + public string? IpAddress { get; init; } + + /// + /// Gets the raw user-agent string for web clients. + /// Used when deeper parsing of browser or device details is needed. + /// + public string? UserAgent { get; init; } + + /// + /// Gets a device fingerprint or unique client identifier if provided by the + /// application. Useful for advanced session policies or fraud analysis. + /// + public string? Fingerprint { get; init; } + + /// + /// Indicates whether the device is considered trusted by the user or system. + /// Applications may update this value when implementing trusted-device flows. + /// + public bool? IsTrusted { get; init; } + + /// + /// Gets optional custom metadata supplied by the application. + /// Allows additional device attributes not covered by standard fields. + /// + public Dictionary? Custom { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs new file mode 100644 index 00000000..878d6b23 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -0,0 +1,73 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Represents a single authentication session belonging to a user. + /// Sessions are immutable, security-critical units used for validation, + /// sliding expiration, revocation, and device analytics. + /// + public interface ISession + { + /// + /// Gets the unique identifier of the session. + /// + AuthSessionId SessionId { get; } + + /// + /// Gets the identifier of the user who owns this session. + /// + TUserId UserId { get; } + + /// + /// Gets the timestamp when this session was originally created. + /// + DateTime CreatedAt { get; } + + /// + /// Gets the timestamp when the session becomes invalid due to expiration. + /// + DateTime ExpiresAt { get; } + + /// + /// Gets the timestamp of the last successful usage. + /// Used when evaluating sliding expiration policies. + /// + DateTime LastSeenAt { get; } + + /// + /// Gets a value indicating whether this session has been explicitly revoked. + /// + bool IsRevoked { get; } + + /// + /// Gets the timestamp when the session was revoked, if applicable. + /// + DateTime? RevokedAt { get; } + + /// + /// Gets the user's security version at the moment of session creation. + /// If the stored version does not match the user's current version, + /// the session becomes invalid (e.g., after password or MFA reset). + /// + long SecurityVersionAtCreation { get; } + + /// + /// Gets metadata describing the client device that created the session. + /// Includes platform, OS, IP address, fingerprint, and more. + /// + DeviceInfo Device { get; } + + /// + /// Gets session-scoped metadata used for application-specific extensions, + /// such as tenant data, app version, locale, or CSRF tokens. + /// + SessionMetadata Metadata { get; } + + /// + /// Computes the effective runtime state of the session (Active, Expired, + /// Revoked, SecurityVersionMismatch, etc.) based on the provided timestamp. + /// + /// Current timestamp used for comparisons. + /// The evaluated of this session. + SessionState GetState(DateTime now); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs new file mode 100644 index 00000000..b7034df1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs @@ -0,0 +1,59 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Represents a device- or login-scoped session chain. + /// A chain groups all rotated sessions belonging to a single logical login + /// (e.g., a browser instance, mobile app installation, or device fingerprint). + /// + public interface ISessionChain + { + /// + /// Gets the unique identifier of the session chain. + /// + ChainId ChainId { get; } + + /// + /// Gets the identifier of the user who owns this chain. + /// Each chain represents one device/login family for this user. + /// + TUserId UserId { get; } + + /// + /// Gets the number of refresh token rotations performed within this chain. + /// + int RotationCount { get; } + + /// + /// Gets the user's security version at the time the chain was created. + /// If the user's current security version is higher, the entire chain + /// becomes invalid (e.g., after password or MFA reset). + /// + long SecurityVersionAtCreation { get; } + + /// + /// Gets an optional snapshot of claims taken at chain creation time. + /// Useful for offline clients, WASM apps, and environments where + /// full user lookup cannot be performed on each request. + /// + IReadOnlyDictionary? ClaimsSnapshot { get; } + + /// + /// Gets the list of all rotated sessions created within this chain. + /// The newest session is always considered the active one. + /// + IReadOnlyList> Sessions { get; } + + /// + /// Gets a value indicating whether this chain has been revoked. + /// Revoking a chain performs a device-level logout, invalidating + /// all sessions it contains. + /// + bool IsRevoked { get; } + + /// + /// Gets the timestamp when the chain was revoked, if applicable. + /// + DateTime? RevokedAt { get; } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs new file mode 100644 index 00000000..c3d3baf5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs @@ -0,0 +1,54 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Represents the root container for all authentication session chains of a user. + /// A session root is tenant-scoped and acts as the authoritative security boundary, + /// controlling global revocation, security versioning, and device/login families. + /// + public interface ISessionRoot + { + /// + /// Gets the identifier of the user who owns this session root. + /// Each user has one root per tenant. + /// + TUserId UserId { get; } + + /// + /// Gets the tenant identifier associated with this session root. + /// Used to isolate authentication domains in multi-tenant systems. + /// + string? TenantId { get; } + + /// + /// Gets a value indicating whether the entire session root is revoked. + /// When true, all chains and sessions belonging to this root are invalid, + /// regardless of their individual states. + /// + bool IsRevoked { get; } + + /// + /// Gets the timestamp when the session root was revoked, if applicable. + /// + DateTime? RevokedAt { get; } + + /// + /// Gets the current security version of the user within this tenant. + /// Incrementing this value invalidates all sessions, even if they are still active. + /// Common triggers include password reset, MFA reset, and account recovery. + /// + long SecurityVersion { get; } + + /// + /// Gets the complete set of session chains associated with this root. + /// Each chain represents a device or login-family (browser instance, mobile app, etc.). + /// The root is immutable; modifications must go through SessionService or SessionStore. + /// + IReadOnlyList> Chains { get; } + + /// + /// Gets the timestamp when this root structure was last updated. + /// Useful for caching, concurrency handling, and incremental synchronization. + /// + DateTime LastUpdatedAt { get; } + } +} 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..a4963810 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs @@ -0,0 +1,40 @@ +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 + { + /// + /// 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 the tenant identifier attached to this session, if applicable. + /// This value may override or complement root-level multi-tenant resolution. + /// + public string? TenantId { 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/SessionState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs new file mode 100644 index 00000000..0faaf751 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs @@ -0,0 +1,44 @@ +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 + { + /// + /// The session is valid, not expired, not revoked, and its security version + /// matches the user's current security version. + /// + Active = 0, + + /// + /// The session has passed its expiration time and is no longer valid. + /// + Expired = 1, + + /// + /// The session was explicitly revoked by user action or administrative control. + /// + Revoked = 2, + + /// + /// The session's parent chain has been revoked, typically representing a + /// device-level logout or device ban. + /// + ChainRevoked = 3, + + /// + /// The user's entire session root has been revoked. This invalidates all + /// chains and sessions immediately across all devices. + /// + RootRevoked = 4, + + /// + /// The session's stored SecurityVersionAtCreation does not match the user's + /// current security version, indicating a password reset, MFA reset, + /// or other critical security event. + /// + SecurityVersionMismatch = 5 + } +} 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..39b340d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an exception that indicates a developer integration error + /// rather than a runtime or authentication failure. + /// These errors typically occur when UltimateAuth is misconfigured, + /// required services are not registered, or contracts are violated by the host application. + /// + public abstract class UAuthDeveloperException : UAuthException + { + /// + /// Initializes a new instance of the class + /// with a specified error message describing the developer mistake. + /// + /// The error message explaining the incorrect usage. + protected UAuthDeveloperException(string message) : base(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..4894aabf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an exception triggered by a violation of UltimateAuth's domain rules or invariants. + /// These errors indicate that a business rule or authentication domain constraint has been broken (e.g., invalid session state transition, + /// illegal revoke action, or inconsistent security version). + /// + public abstract class UAuthDomainException : UAuthException + { + /// + /// Initializes a new instance of the class with a message describing the violated domain rule. + /// + /// The descriptive message for the domain error. + protected UAuthDomainException(string message) : base(message) { } + } +} 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..08ea3e15 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents the base type for all exceptions thrown by the UltimateAuth framework. + /// This class differentiates authentication-domain errors from general system exceptions + /// and provides a common abstraction for developer, domain, and runtime error types. + /// + public abstract class UAuthException : Exception + { + /// + /// Initializes a new instance of the class + /// with the specified error message. + /// + /// The message that describes the error. + protected UAuthException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class + /// with the specified error message and underlying exception. + /// + /// The message that describes the error. + /// The exception that caused the current error. + protected UAuthException(string message, Exception? inner) : base(message, inner) { } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs new file mode 100644 index 00000000..7836b57b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents a domain-level exception associated with a specific authentication session. + /// This error indicates that a session-related invariant or rule has been violated, + /// such as attempting to refresh a revoked session, using an expired session, + /// or performing an operation that conflicts with the session's current state. + /// + public abstract class UAuthSessionException : UAuthDomainException + { + /// + /// Gets the identifier of the session that triggered the exception. + /// + public AuthSessionId SessionId { get; } + + /// + /// Initializes a new instance of the class with the session identifier and an explanatory error message. + /// + /// The session identifier associated with the error. + /// The message describing the session rule violation. + protected UAuthSessionException(AuthSessionId sessionId, string message) : base(message) + { + SessionId = sessionId; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs new file mode 100644 index 00000000..d247a6b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an exception that is thrown when UltimateAuth is configured + /// incorrectly or when required configuration values are missing or invalid. + /// This error indicates a developer-side setup issue rather than a runtime + /// authentication failure. + /// + public sealed class UAuthConfigException : UAuthDeveloperException + { + /// + /// Initializes a new instance of the class + /// with a descriptive message explaining the configuration problem. + /// + /// The message describing the configuration error. + public UAuthConfigException(string message) : base(message) { } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs new file mode 100644 index 00000000..ad6678a3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs @@ -0,0 +1,20 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an unexpected internal error within the UltimateAuth framework. + /// This exception indicates a failure in internal logic, invariants, or service + /// coordination, rather than a configuration or authentication mistake by the developer. + /// + /// If this exception occurs, it typically means a bug or unhandled scenario + /// exists inside the framework itself. + /// + public sealed class UAuthInternalException : UAuthDeveloperException + { + /// + /// Initializes a new instance of the class + /// with a descriptive message explaining the internal framework error. + /// + /// The internal error message. + public UAuthInternalException(string message) : base(message) { } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs new file mode 100644 index 00000000..13465d93 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an exception that occurs when a session or user store + /// behaves incorrectly or violates the UltimateAuth storage contract. + /// This typically indicates an implementation error in the application's + /// persistence layer rather than a framework or authentication issue. + /// + public sealed class UAuthStoreException : UAuthDeveloperException + { + /// + /// Initializes a new instance of the class + /// with a descriptive message explaining the store failure. + /// + /// The message describing the store-related error. + public UAuthStoreException(string message) : base(message) { } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs new file mode 100644 index 00000000..16152021 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents a domain-level exception that is thrown when a user exceeds the allowed number of device or platform-specific session chains. + /// This typically occurs when UltimateAuth's session policy restricts the + /// number of concurrent logins for a given platform (e.g., web, mobile) + /// and the user attempts to create an additional session beyond the limit. + /// + public sealed class UAuthDeviceLimitException : UAuthDomainException + { + /// + /// Gets the platform for which the device or session-chain limit was exceeded. + /// + public string Platform { get; } + + /// + /// Initializes a new instance of the class with the specified platform name. + /// + /// The platform on which the limit was exceeded. + public UAuthDeviceLimitException(string platform) : base($"Device limit exceeded for platform '{platform}'.") + { + Platform = platform; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs new file mode 100644 index 00000000..05941d5d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an authentication failure caused by invalid user credentials. + /// This error is thrown when the supplied username, password, or login + /// identifier does not match any valid user account. + /// + public sealed class UAuthInvalidCredentialsException : UAuthDomainException + { + /// + /// Initializes a new instance of the class + /// with a default message indicating incorrect credentials. + /// + public UAuthInvalidCredentialsException() : base("Invalid username or password.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs new file mode 100644 index 00000000..51905d1d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs @@ -0,0 +1,20 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an authentication failure occurring during the PKCE authorization + /// flow when the supplied authorization code is invalid, expired, or does not + /// match the original code challenge. + /// This exception indicates a failed PKCE verification rather than a general + /// credential or configuration error. + /// + public sealed class UAuthInvalidPkceCodeException : UAuthDomainException + { + /// + /// Initializes a new instance of the class + /// with a default message indicating an invalid PKCE authorization code. + /// + public UAuthInvalidPkceCodeException() : base("Invalid PKCE authorization code.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs new file mode 100644 index 00000000..fc1ad258 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs @@ -0,0 +1,20 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents a domain-level authentication failure indicating that the user's + /// entire session root has been revoked. + /// When a root is revoked, all session chains and all sessions belonging to the + /// user become immediately invalid, regardless of their individual expiration + /// or revocation state. + /// + public sealed class UAuthRootRevokedException : UAuthDomainException + { + /// + /// Initializes a new instance of the class + /// with a default message indicating that all sessions under the root are invalid. + /// + public UAuthRootRevokedException() : base("User root has been revoked. All sessions are invalid.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs new file mode 100644 index 00000000..b312ddef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs @@ -0,0 +1,37 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents a domain-level authentication failure caused by a mismatch + /// between the session's stored security version and the user's current + /// security version. + /// A mismatch indicates that a critical security event has occurred + /// after the session was created—such as a password reset, MFA reset, + /// account recovery, or other action requiring all prior sessions + /// to be invalidated. + /// + public sealed class UAuthSecurityVersionMismatchException : UAuthDomainException + { + /// + /// Gets the security version captured when the session was created. + /// + public long SessionVersion { get; } + + /// + /// Gets the user's current security version, which has increased + /// since the session was issued. + /// + public long UserVersion { get; } + + /// + /// Initializes a new instance of the class + /// using the session's stored version and the user's current version. + /// + /// The security version value stored in the session. + /// The user's current security version. + public UAuthSecurityVersionMismatchException(long sessionVersion, long userVersion) : base($"Security version mismatch. Session={sessionVersion}, User={userVersion}") + { + SessionVersion = sessionVersion; + UserVersion = userVersion; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionExpiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionExpiredException.cs new file mode 100644 index 00000000..f84fa777 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionExpiredException.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an authentication-domain exception thrown when a session + /// has passed its expiration time. + /// + /// This exception is raised during validation or refresh attempts where + /// the session's timestamp + /// indicates that it is no longer valid. + /// + /// Once expired, a session cannot be refreshed — the user must log in again. + /// + public sealed class UAuthSessionExpiredException : UAuthSessionException + { + /// + /// Initializes a new instance of the class + /// using the expired session's identifier. + /// + /// The identifier of the expired session. + public UAuthSessionExpiredException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' has expired.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionNotActiveException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionNotActiveException.cs new file mode 100644 index 00000000..21f5aae7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionNotActiveException.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an authentication-domain exception thrown when a session exists + /// but is not in the state. + /// This exception typically occurs during validation or refresh operations when: + /// - the session is revoked, + /// - the session has expired, + /// - the session belongs to a revoked chain, + /// - or the session is otherwise considered inactive by the runtime state machine. + /// Only active sessions are eligible for refresh and token issuance. + /// + public sealed class UAuthSessionNotActiveException : UAuthSessionException + { + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the session that is not active. + public UAuthSessionNotActiveException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' is not active.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionRevokedException.cs new file mode 100644 index 00000000..7d7660c3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionRevokedException.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an authentication-domain exception thrown when an operation attempts + /// to use a session that has been explicitly revoked by the user, administrator, + /// or by system-driven security policies. + /// + /// A revoked session is permanently invalid and cannot be refreshed, validated, + /// or used to obtain new tokens. Revocation typically occurs during actions such as + /// logout, device removal, or administrative account lockdown. + /// + /// This exception is raised in scenarios where a caller assumes the session is active + /// but the underlying session state indicates . + /// + public sealed class UAuthSessionRevokedException : UAuthSessionException + { + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the revoked session. + public UAuthSessionRevokedException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' has been revoked.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs new file mode 100644 index 00000000..0164df5f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs @@ -0,0 +1,26 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + /// + /// Represents an authentication-domain exception thrown when a token fails its + /// integrity verification checks, indicating that the token may have been altered, + /// corrupted, or tampered with after issuance. + /// + /// This exception is raised during token validation when signature verification fails, + /// claims are inconsistent, or protected fields do not match their expected values. + /// Such failures generally imply either client-side manipulation or + /// man-in-the-middle interference. + /// + /// Applications catching this exception should treat the associated token as unsafe + /// and deny access immediately. Reauthentication or complete session invalidation + /// may be required depending on the security policy. + /// + public sealed class UAuthTokenTamperedException : UAuthDomainException + { + /// + /// Initializes a new instance of the class. + /// + public UAuthTokenTamperedException() : base("Token integrity check failed (possible tampering).") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs new file mode 100644 index 00000000..2c786396 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Events +{ + /// + /// Marker interface for all UltimateAuth event context types. + /// + public interface IAuthEventContext { } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs new file mode 100644 index 00000000..532c86a0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs @@ -0,0 +1,48 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Events +{ + /// + /// Represents contextual data emitted when a new authentication session is created. + /// + /// This event is published immediately after a successful login or initial session + /// creation within a session chain. It provides the essential identifiers required + /// for auditing, monitoring, analytics, and external integrations. + /// + /// Handlers should treat this event as notification-only; modifying session state + /// or performing security-critical actions is not recommended unless explicitly intended. + /// + public sealed class SessionCreatedContext : IAuthEventContext + { + /// + /// Gets the identifier of the user for whom the new session was created. + /// + public TUserId UserId { get; } + + /// + /// Gets the unique identifier of the newly created session. + /// + public AuthSessionId SessionId { get; } + + /// + /// Gets the identifier of the session chain to which this session belongs. + /// + public ChainId ChainId { get; } + + /// + /// Gets the timestamp on which the session was created. + /// + public DateTime CreatedAt { get; } + + /// + /// Initializes a new instance of the class. + /// + public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, ChainId chainId, DateTime createdAt) + { + UserId = userId; + SessionId = sessionId; + ChainId = chainId; + CreatedAt = createdAt; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs new file mode 100644 index 00000000..85ab19e2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs @@ -0,0 +1,60 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Events +{ + /// + /// Represents contextual data emitted when an authentication session is refreshed. + /// + /// This event occurs whenever a valid session performs a rotation — typically during + /// a refresh-token exchange or session renewal flow. The old session becomes inactive, + /// and a new session inherits updated expiration and security metadata. + /// + /// This event is primarily used for analytics, auditing, security monitoring, and + /// external workflow triggers (e.g., notifying users of new logins, updating dashboards, + /// or tracking device activity). + /// + public sealed class SessionRefreshedContext : IAuthEventContext + { + /// + /// Gets the identifier of the user whose session was refreshed. + /// + public TUserId UserId { get; } + + /// + /// Gets the identifier of the session that was replaced during the refresh operation. + /// + public AuthSessionId OldSessionId { get; } + + /// + /// Gets the identifier of the newly created session that replaces the old session. + /// + public AuthSessionId NewSessionId { get; } + + /// + /// Gets the identifier of the session chain to which both sessions belong. + /// + public ChainId ChainId { get; } + + /// + /// Gets the timestamp at which the refresh occurred. + /// + public DateTime RefreshedAt { get; } + + /// + /// Initializes a new instance of the class. + /// + public SessionRefreshedContext( + TUserId userId, + AuthSessionId oldSessionId, + AuthSessionId newSessionId, + ChainId chainId, + DateTime refreshedAt) + { + UserId = userId; + OldSessionId = oldSessionId; + NewSessionId = newSessionId; + ChainId = chainId; + RefreshedAt = refreshedAt; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs new file mode 100644 index 00000000..bd19b7e2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs @@ -0,0 +1,57 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Events +{ + /// + /// Represents contextual data emitted when an individual session is revoked. + /// + /// This event is triggered when a specific session is invalidated — either due to + /// explicit logout, administrator action, security enforcement, or anomaly detection. + /// Only the targeted session is revoked; other sessions in the same chain or root + /// may continue to remain active unless broader revocation policies apply. + /// + /// Typical use cases include: + /// - Auditing and compliance logs + /// - User notifications (e.g., “Your session on device X was logged out”) + /// - Security automations (SIEM integration, monitoring suspicious activity) + /// - Application workflows that must respond to session termination + /// + public sealed class SessionRevokedContext : IAuthEventContext + { + /// + /// Gets the identifier of the user to whom the revoked session belongs. + /// + public TUserId UserId { get; } + + /// + /// Gets the identifier of the session that has been revoked. + /// + public AuthSessionId SessionId { get; } + + /// + /// Gets the identifier of the session chain containing the revoked session. + /// + public ChainId ChainId { get; } + + /// + /// Gets the timestamp at which the session revocation occurred. + /// + public DateTime RevokedAt { get; } + + /// + /// Initializes a new instance of the class. + /// + public SessionRevokedContext( + TUserId userId, + AuthSessionId sessionId, + ChainId chainId, + DateTime revokedAt) + { + UserId = userId; + SessionId = sessionId; + ChainId = chainId; + RevokedAt = revokedAt; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs new file mode 100644 index 00000000..55522192 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs @@ -0,0 +1,53 @@ +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 SessionCreatedContext c: + if (_events.OnSessionCreated != null) + await SafeInvoke(() => _events.OnSessionCreated(c)); + break; + + case SessionRefreshedContext c: + if (_events.OnSessionRefreshed != null) + await SafeInvoke(() => _events.OnSessionRefreshed(c)); + break; + + case SessionRevokedContext c: + if (_events.OnSessionRevoked != null) + await SafeInvoke(() => _events.OnSessionRevoked(c)); + break; + + 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..5d67cd5a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs @@ -0,0 +1,57 @@ +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 new session is created (login or device bootstrap). + /// + public Func, Task>? OnSessionCreated { get; set; } + + /// + /// Fired when an existing session is refreshed and rotated. + /// + public Func, Task>? OnSessionRefreshed { get; set; } + + /// + /// Fired when a specific session is revoked. + /// + public Func, Task>? OnSessionRevoked { get; set; } + + /// + /// Fired when a user successfully completes the login process. + /// Note: separate from SessionCreated; this is a higher-level event. + /// + public Func, Task>? OnUserLoggedIn { get; set; } + + /// + /// Fired when a user logs out or all sessions for the user are revoked. + /// + public Func, Task>? OnUserLoggedOut { get; set; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs new file mode 100644 index 00000000..f6f4af0f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs @@ -0,0 +1,42 @@ +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 . + /// 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 + { + /// + /// Gets the identifier of the user who has logged in. + /// + public TUserId UserId { get; } + + /// + /// Gets the timestamp at which the login event occurred. + /// + public DateTime LoggedInAt { get; } + + /// + /// Initializes a new instance of the class. + /// + public UserLoggedInContext(TUserId userId, DateTime at) + { + UserId = userId; + LoggedInAt = at; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs new file mode 100644 index 00000000..311a87cc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs @@ -0,0 +1,41 @@ +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 , 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 + { + /// + /// Gets the identifier of the user who has logged out. + /// + public TUserId UserId { get; } + + /// + /// Gets the timestamp at which the logout occurred. + /// + public DateTime LoggedOutAt { get; } + + /// + /// Initializes a new instance of the class. + /// + public UserLoggedOutContext(TUserId userId, DateTime at) + { + UserId = userId; + LoggedOutAt = at; + } + } +} 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/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs new file mode 100644 index 00000000..378585ea --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Utilities; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Extensions +{ + // TODO: Check it before stable release + /// + /// 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 UltimateAuthServiceCollectionExtensions + { + /// + /// Registers UltimateAuth services using configuration binding (e.g., appsettings.json). + /// + /// The provided configuration section must contain valid UltimateAuthOptions and nested + /// Session, Token, PKCE, and MultiTenant configuration sections. Validation occurs + /// at application startup via IValidateOptions. + /// + public static IServiceCollection AddUltimateAuth(this IServiceCollection services, IConfiguration configurationSection) + { + services.Configure(configurationSection); + return services.AddUltimateAuthInternal(); + } + + /// + /// Registers UltimateAuth services using programmatic configuration. + /// This is useful when settings are derived dynamically or are not stored + /// in appsettings.json. + /// + public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action configure) + { + services.Configure(configure); + return services.AddUltimateAuthInternal(); + } + + /// + /// Registers UltimateAuth services using default empty configuration. + /// Intended for advanced or fully manual scenarios where options will be + /// configured later or overridden by the server layer. + /// + public static IServiceCollection AddUltimateAuth(this IServiceCollection services) + { + services.Configure(_ => { }); + return services.AddUltimateAuthInternal(); + } + + /// + /// Internal shared registration pipeline invoked by all AddUltimateAuth overloads. + /// Registers validators, user ID converters, and placeholder factories. + /// + /// 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.AddSingleton, UltimateAuthOptionsValidator>(); + services.AddSingleton, SessionOptionsValidator>(); + services.AddSingleton, TokenOptionsValidator>(); + services.AddSingleton, PkceOptionsValidator>(); + services.AddSingleton, MultiTenantOptionsValidator>(); + + // Binding of nested sub-options (Session, Token, etc.) is intentionally not done here. + // These must be bound at the server level to allow configuration per-environment. + + services.AddSingleton(); + + // Default factory throws until a real session store is registered. + services.TryAddSingleton(); + + return services; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs new file mode 100644 index 00000000..2037e0bb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs @@ -0,0 +1,102 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Core.Extensions +{ + /// + /// Provides extension methods for registering a concrete + /// implementation into the application's dependency injection container. + /// + /// UltimateAuth requires exactly one session store implementation that determines + /// how sessions, chains, and roots are persisted (e.g., EF Core, Dapper, Redis, MongoDB). + /// This extension performs automatic generic type resolution and registers the correct + /// ISessionStore<TUserId> for the application's user ID type. + /// + /// The method enforces that the provided store implements ISessionStore'TUserId';. + /// If the type cannot be determined, an exception is thrown to prevent misconfiguration. + /// + public static class UltimateAuthSessionStoreExtensions + { + /// + /// Registers a custom session store implementation for UltimateAuth. + /// The supplied must implement ISessionStore'TUserId'; + /// exactly once with a single TUserId generic argument. + /// + /// After registration, the internal session store factory resolves the correct + /// ISessionStore instance at runtime for the active tenant and TUserId type. + /// + /// The concrete session store implementation. + public static IServiceCollection AddUltimateAuthSessionStore(this IServiceCollection services) + where TStore : class + { + var storeInterface = typeof(TStore) + .GetInterfaces() + .FirstOrDefault(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(ISessionStore<>)); + + if (storeInterface is null) + { + throw new InvalidOperationException( + $"{typeof(TStore).Name} must implement ISessionStore."); + } + + var userIdType = storeInterface.GetGenericArguments()[0]; + var typedInterface = typeof(ISessionStore<>).MakeGenericType(userIdType); + + services.TryAddScoped(typedInterface, typeof(TStore)); + + services.AddSingleton(sp => + new GenericSessionStoreFactory(sp, userIdType)); + + return services; + } + } + + /// + /// Default session store factory used by UltimateAuth to dynamically create + /// the correct ISessionStore<TUserId> implementation at runtime. + /// + /// This factory ensures type safety by validating the requested TUserId against + /// the registered session store’s user ID type. Attempting to resolve a mismatched + /// TUserId results in a descriptive exception to prevent silent misconfiguration. + /// + /// Tenant ID is passed through so that multi-tenant implementations can perform + /// tenant-aware routing, filtering, or partition-based selection. + /// + internal sealed class GenericSessionStoreFactory : ISessionStoreFactory + { + private readonly IServiceProvider _sp; + private readonly Type _userIdType; + + /// + /// Initializes a new instance of the class. + /// + public GenericSessionStoreFactory(IServiceProvider sp, Type userIdType) + { + _sp = sp; + _userIdType = userIdType; + } + + /// + /// Creates and returns the registered ISessionStore<TUserId> implementation + /// for the specified tenant and user ID type. + /// Throws if the requested TUserId does not match the registered store's type. + /// + public ISessionStore Create(string? tenantId) + { + if (typeof(TUserId) != _userIdType) + { + throw new InvalidOperationException( + $"SessionStore registered for TUserId='{_userIdType.Name}', " + + $"but requested with TUserId='{typeof(TUserId).Name}'."); + } + + var typed = typeof(ISessionStore<>).MakeGenericType(_userIdType); + var store = _sp.GetRequiredService(typed); + + return (ISessionStore)store; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs new file mode 100644 index 00000000..6c40f103 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Extensions +{ + /// + /// 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/Internal/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSession.cs new file mode 100644 index 00000000..69a9cfdc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSession.cs @@ -0,0 +1,71 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Internal +{ + internal sealed class UAuthSession : ISession + { + public UAuthSession(AuthSessionId sessionId, TUserId userId, DateTime createdAt, DateTime expiresAt, DateTime lastSeenAt, + long securityVersionAtCreation, DeviceInfo device, SessionMetadata metadata, bool isRevoked = false, + DateTime? revokedAt = null) + { + SessionId = sessionId; + UserId = userId; + CreatedAt = createdAt; + ExpiresAt = expiresAt; + LastSeenAt = lastSeenAt; + SecurityVersionAtCreation = securityVersionAtCreation; + Device = device; + Metadata = metadata; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + } + + public AuthSessionId SessionId { get; } + public TUserId UserId { get; } + + public DateTime CreatedAt { get; } + public DateTime ExpiresAt { get; } + public DateTime LastSeenAt { get; } + + public long SecurityVersionAtCreation { get; } + + public bool IsRevoked { get; } + public DateTime? RevokedAt { get; } + + public DeviceInfo Device { get; } + public SessionMetadata Metadata { get; } + + public SessionState GetState(DateTime now) + { + if (IsRevoked) return SessionState.Revoked; + if (now >= ExpiresAt) return SessionState.Expired; + + return SessionState.Active; + } + + public static UAuthSession CreateNew(TUserId userId, long rootSecurityVersion, DeviceInfo device, SessionMetadata metadata, DateTime now, TimeSpan lifetime) + { + return new UAuthSession( + sessionId: AuthSessionId.New(), + userId: userId, + createdAt: now, + expiresAt: now.Add(lifetime), + lastSeenAt: now, + securityVersionAtCreation: rootSecurityVersion, + device: device, + metadata: metadata + ); + } + + // TODO: WithUpdatedLastSeenAt? Add as optionally used in session validation flow. + public UAuthSession WithRevoked(DateTime at) + { + return new UAuthSession( + SessionId, UserId, CreatedAt, ExpiresAt, LastSeenAt, + SecurityVersionAtCreation, Device, Metadata, + isRevoked: true, + revokedAt: at + ); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionChain.cs new file mode 100644 index 00000000..4f10170d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionChain.cs @@ -0,0 +1,76 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Internal +{ + internal sealed class UAuthSessionChain : ISessionChain + { + public UAuthSessionChain(ChainId chainId, TUserId userId, int rotationCount, long securityVersionAtCreation, IReadOnlyDictionary? claimsSnapshot, + IReadOnlyList> sessions, bool isRevoked = false, DateTime? revokedAt = null) + { + ChainId = chainId; + UserId = userId; + RotationCount = rotationCount; + SecurityVersionAtCreation = securityVersionAtCreation; + ClaimsSnapshot = claimsSnapshot; + Sessions = sessions; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + } + + public ChainId ChainId { get; } + public TUserId UserId { get; } + + public int RotationCount { get; } + public long SecurityVersionAtCreation { get; } + + public IReadOnlyDictionary? ClaimsSnapshot { get; } + public IReadOnlyList> Sessions { get; } + + public bool IsRevoked { get; } + public DateTime? RevokedAt { get; } + + public static UAuthSessionChain CreateNew(TUserId userId, long rootSecurityVersion, ISession initialSession, IReadOnlyDictionary? claimsSnapshot) + { + return new UAuthSessionChain( + chainId: ChainId.New(), + userId: userId, + rotationCount: 0, + securityVersionAtCreation: rootSecurityVersion, + claimsSnapshot: claimsSnapshot, + sessions: new[] { initialSession } + ); + } + + public UAuthSessionChain AddRotatedSession(ISession session) + { + var newList = new List>(Sessions.Count + 1); + newList.AddRange(Sessions); + newList.Add(session); + + return new UAuthSessionChain( + ChainId, + UserId, + RotationCount + 1, + SecurityVersionAtCreation, + ClaimsSnapshot, + newList, + IsRevoked, + RevokedAt + ); + } + + public UAuthSessionChain WithRevoked(DateTime at) + { + return new UAuthSessionChain( + ChainId, + UserId, + RotationCount, + SecurityVersionAtCreation, + ClaimsSnapshot, + Sessions, + isRevoked: true, + revokedAt: at + ); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionRoot.cs new file mode 100644 index 00000000..9696f6cc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionRoot.cs @@ -0,0 +1,95 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Internal +{ + internal sealed class UAuthSessionRoot : ISessionRoot + { + public UAuthSessionRoot(string? tenantId, TUserId userId, bool isRevoked, DateTime? revokedAt, long securityVersion, IReadOnlyList> chains, DateTime lastUpdatedAt) + { + TenantId = tenantId; + UserId = userId; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + SecurityVersion = securityVersion; + Chains = chains; + LastUpdatedAt = lastUpdatedAt; + } + + public string? TenantId { get; } + public TUserId UserId { get; } + public bool IsRevoked { get; } + public DateTime? RevokedAt { get; } + public long SecurityVersion { get; } + public IReadOnlyList> Chains { get; } + public DateTime LastUpdatedAt { get; } + + public static UAuthSessionRoot CreateNew(string? tenantId, TUserId userId, DateTime now) + { + return new UAuthSessionRoot( + tenantId: tenantId, + userId: userId, + isRevoked: false, + revokedAt: null, + securityVersion: 1, + chains: Array.Empty>(), + lastUpdatedAt: now + ); + } + + public UAuthSessionRoot AddChain(ISessionChain chain, DateTime now) + { + var newList = new List>(Chains.Count + 1); + newList.AddRange(Chains); + newList.Add(chain); + + return new UAuthSessionRoot( + TenantId, + UserId, + IsRevoked, + RevokedAt, + SecurityVersion, + newList, + lastUpdatedAt: now + ); + } + + public UAuthSessionRoot WithSecurityVersionIncrement(DateTime now) + { + return new UAuthSessionRoot( + TenantId, + UserId, + IsRevoked, + RevokedAt, + securityVersion: SecurityVersion + 1, + Chains, + lastUpdatedAt: now + ); + } + + public UAuthSessionRoot WithRevoked(DateTime at) + { + return new UAuthSessionRoot( + TenantId, + UserId, + isRevoked: true, + revokedAt: at, + SecurityVersion, + Chains, + lastUpdatedAt: at + ); + } + + public UAuthSessionRoot WithUnrevoked(DateTime now) + { + return new UAuthSessionRoot( + TenantId, + UserId, + isRevoked: false, + revokedAt: null, + SecurityVersion, + Chains, + lastUpdatedAt: now + ); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionService.cs new file mode 100644 index 00000000..c8a5da2a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionService.cs @@ -0,0 +1,170 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Models; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Internal +{ + internal sealed class UAuthSessionService : ISessionService + { + private readonly ISessionStore _store; + private readonly SessionOptions _options; + + public UAuthSessionService(ISessionStore store, SessionOptions options) + { + _store = store; + _options = options; + } + + public async Task> CreateLoginSessionAsync(string? tenantId, TUserId userId, DeviceInfo device, SessionMetadata? metadata, DateTime now) + { + var root = await _store.GetSessionRootAsync(tenantId, userId) ?? UAuthSessionRoot.CreateNew(tenantId, userId, now); + + var session = UAuthSession.CreateNew( + userId, + root.SecurityVersion, + device, + metadata ?? new SessionMetadata(), + now, + _options.Lifetime + ); + + var chain = UAuthSessionChain.CreateNew( + userId, + root.SecurityVersion, + session, + claimsSnapshot: null + ); + + var concreteRoot = (UAuthSessionRoot)root; + var updatedRoot = concreteRoot.AddChain(chain, now); + + await _store.SaveSessionAsync(tenantId, session); + await _store.SaveChainAsync(tenantId, chain); + await _store.SaveSessionRootAsync(tenantId, updatedRoot); + await _store.SetActiveSessionIdAsync(tenantId, chain.ChainId, session.SessionId); + + return new SessionResult + { + Session = session, + Chain = chain, + Root = updatedRoot + }; + } + + public async Task> RefreshSessionAsync(string? tenantId, AuthSessionId currentSessionId, DateTime now) + { + var oldSession = await _store.GetSessionAsync(tenantId, currentSessionId) ?? throw new InvalidOperationException("Session not found"); + + var chainId = await _store.GetChainIdBySessionAsync(tenantId, currentSessionId) + ?? throw new InvalidOperationException("Chain not found"); + + var chain = await _store.GetChainAsync(tenantId, chainId) + ?? throw new InvalidOperationException("Chain missing"); + + var root = await _store.GetSessionRootAsync(tenantId, oldSession.UserId) + ?? throw new InvalidOperationException("Root missing"); + + if (root.IsRevoked) + throw new UnauthorizedAccessException("Root revoked"); + + if (chain.IsRevoked) + throw new UnauthorizedAccessException("Chain revoked"); + + if (oldSession.SecurityVersionAtCreation != root.SecurityVersion) + throw new UnauthorizedAccessException("SecurityVersion mismatch"); + + if (now >= oldSession.ExpiresAt) + throw new UnauthorizedAccessException("Session expired"); + + var newSession = UAuthSession.CreateNew( + oldSession.UserId, + root.SecurityVersion, + oldSession.Device, + oldSession.Metadata, + now, + _options.Lifetime + ); + + var concreteChain = (UAuthSessionChain)chain; + var rotatedChain = concreteChain.AddRotatedSession(newSession); + + await _store.SaveSessionAsync(tenantId, newSession); + await _store.UpdateChainAsync(tenantId, rotatedChain); + await _store.SetActiveSessionIdAsync(tenantId, chain.ChainId, newSession.SessionId); + + return new SessionResult + { + Session = newSession, + Chain = rotatedChain, + Root = root + }; + } + + public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at) + { + await _store.RevokeSessionAsync(tenantId, sessionId, at); + } + + public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at) + { + await _store.RevokeChainAsync(tenantId, chainId, at); + } + + public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at) + { + await _store.RevokeSessionRootAsync(tenantId, userId, at); + } + + public async Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime now) + { + var session = await _store.GetSessionAsync(tenantId, sessionId); + + if (session == null) + { + return new SessionValidationResult + { + State = SessionState.Expired + }; + } + + var chainId = await _store.GetChainIdBySessionAsync(tenantId, sessionId); + var chain = chainId == null ? null : await _store.GetChainAsync(tenantId, chainId.Value); + var root = await _store.GetSessionRootAsync(tenantId, session.UserId); + + var state = ComputeState(session, chain, root, now); + + return new SessionValidationResult + { + Session = session, + Chain = chain, + Root = root, + State = state + }; + } + + private SessionState ComputeState(ISession session, ISessionChain? chain, ISessionRoot? root, DateTime now) + { + if (root == null || chain == null) + return SessionState.Expired; + + if (root.IsRevoked) return SessionState.RootRevoked; + if (chain.IsRevoked) return SessionState.ChainRevoked; + + if (session.IsRevoked) return SessionState.Revoked; + if (now >= session.ExpiresAt) return SessionState.Expired; + + if (session.SecurityVersionAtCreation != root.SecurityVersion) + return SessionState.SecurityVersionMismatch; + + return SessionState.Active; + } + + public Task>> GetChainsAsync(string? tenantId, TUserId userId) + => _store.GetChainsByUserAsync(tenantId, userId); + + public Task>> GetSessionsAsync(string? tenantId, ChainId chainId) + => _store.GetSessionsByChainAsync(tenantId, chainId); + + } +} 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/Models/SessionResult.cs b/src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs new file mode 100644 index 00000000..5dbfb062 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Models +{ + // TODO: IsNewChain, IsNewRoot flags? + /// + /// Represents the result of a session operation within UltimateAuth, such as + /// login or session refresh. + /// + /// A session operation may produce: + /// - a newly created session, + /// - an updated session chain (rotation), + /// - an updated session root (e.g., after adding a new chain). + /// + /// This wrapper provides a unified model so downstream components — such as + /// token services, event emitters, logging pipelines, or application-level + /// consumers — can easily access all updated authentication structures. + /// + public sealed class SessionResult + { + /// + /// Gets the active session produced by the operation. + /// This is the newest session and the one that should be used when issuing tokens. + /// + public required ISession Session { get; init; } + + /// + /// Gets the session chain associated with the session. + /// The chain may be newly created (login) or updated (session rotation). + /// + public required ISessionChain Chain { get; init; } + + /// + /// Gets the user's session root. + /// This structure may be updated when new chains are added or when security + /// properties change. + /// + public required ISessionRoot Root { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs new file mode 100644 index 00000000..31560b15 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Models +{ + /// + /// Represents the outcome of validating a session, including the resolved session, + /// its chain and root structures, and the computed validation state. + /// + /// + /// Session, Chain and Root may be null if validation fails or if the session + /// does not exist. State always indicates the final resolved status. + /// + public sealed class SessionValidationResult + { + /// + /// The resolved session instance, or null if the session was not found. + /// + public ISession? Session { get; init; } + + /// + /// The session chain that owns the session, or null if unavailable. + /// + public ISessionChain? Chain { get; init; } + + /// + /// The session root associated with the user, or null if unavailable. + /// + public ISessionRoot? Root { get; init; } + + /// + /// The final computed validation state for the session. + /// + public SessionState State { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs new file mode 100644 index 00000000..1dc03b9c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs @@ -0,0 +1,37 @@ +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 : ITenantResolver + { + 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..84b70d4b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs @@ -0,0 +1,27 @@ +namespace CodeBeam.UltimateAuth.Core.MultiTenancy +{ + /// + /// Returns a constant tenant id for all resolution requests; useful for single-tenant or statically configured systems. + /// + public sealed class FixedTenantResolver : ITenantResolver + { + private readonly string _tenantId; + + /// + /// Creates a resolver that always returns the specified tenant id. + /// + /// The tenant id that will be returned for all requests. + public FixedTenantResolver(string tenantId) + { + _tenantId = tenantId; + } + + /// + /// Returns the fixed tenant id regardless of context. + /// + public Task ResolveTenantIdAsync(TenantResolutionContext context) + { + return 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..bf33d311 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs @@ -0,0 +1,38 @@ +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 : ITenantResolver + { + 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); + } + + 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..00d5bd92 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs @@ -0,0 +1,34 @@ +namespace CodeBeam.UltimateAuth.Core.MultiTenancy +{ + 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 : ITenantResolver + { + /// + /// 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/ITenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantResolver.cs new file mode 100644 index 00000000..bd448a16 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantResolver.cs @@ -0,0 +1,16 @@ +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 ITenantResolver + { + /// + /// 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..11df9746 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs @@ -0,0 +1,40 @@ +namespace CodeBeam.UltimateAuth.Core.MultiTenancy +{ + /// + /// Resolves the tenant id from the request path. + /// Example pattern: /t/{tenantId}/... → returns the extracted tenant id. + /// + public sealed class PathTenantResolver : ITenantResolver + { + private readonly string _prefix; + + /// + /// Creates a resolver that looks for tenant ids under a specific URL prefix. + /// Default prefix is "t", meaning URLs like /t/foo/api will resolve "foo". + /// + public PathTenantResolver(string prefix = "t") + { + _prefix = prefix; + } + + /// + /// 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: /{prefix}/{tenantId}/... + if (segments.Length >= 2 && segments[0] == _prefix) + return Task.FromResult(segments[1]); + + return Task.FromResult(null); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs new file mode 100644 index 00000000..d5fd3a50 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs @@ -0,0 +1,37 @@ +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. + /// + public object? RawContext { get; init; } + } +} 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/LoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/LoginOptions.cs new file mode 100644 index 00000000..320299fd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/LoginOptions.cs @@ -0,0 +1,21 @@ +namespace CodeBeam.UltimateAuth.Core.Options +{ + /// + /// Configuration settings related to interactive user login behavior, + /// including lockout policies and failed-attempt thresholds. + /// + public sealed class LoginOptions + { + /// + /// Maximum number of consecutive failed login attempts allowed + /// before the user is temporarily locked out. + /// + public int MaxFailedAttempts { get; set; } = 5; + + /// + /// Duration (in minutes) for which the user is locked out + /// after exceeding . + /// + public int LockoutMinutes { get; set; } = 15; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptions.cs new file mode 100644 index 00000000..5fae413d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptions.cs @@ -0,0 +1,59 @@ +namespace CodeBeam.UltimateAuth.Core.Options +{ + /// + /// Multi-tenancy configuration for UltimateAuth. + /// Controls whether tenants are required, how they are resolved, + /// and how tenant identifiers are normalized. + /// + public sealed class MultiTenantOptions + { + /// + /// Enables multi-tenant mode. + /// When disabled, all requests operate under a single implicit tenant. + /// + public bool Enabled { get; set; } = false; + + /// + /// If tenant cannot be resolved, this value is used. + /// If null and RequireTenant = true, request fails. + /// + public string? DefaultTenantId { get; set; } = "default"; + + /// + /// If true, a resolved tenant id must always exist. + /// If resolver cannot determine tenant, request will fail. + /// + public bool RequireTenant { get; set; } = false; + + /// + /// If true, a tenant id returned by resolver does NOT need to be known beforehand. + /// If false, unknown tenants must be explicitly registered. + /// (Useful for multi-tenant SaaS with dynamic tenant provisioning) + /// + public bool AllowUnknownTenants { get; set; } = true; + + /// + /// Tenant ids that cannot be used by clients. + /// Protects system-level tenant identifiers. + /// + public HashSet ReservedTenantIds { get; set; } = new() + { + "system", + "root", + "admin", + "public" + }; + + /// + /// If true, tenant identifiers are normalized to lowercase. + /// Recommended for host-based tenancy. + /// + public bool NormalizeToLowercase { get; set; } = true; + + /// + /// Optional validation for tenant id format. + /// Default: alphanumeric + hyphens allowed. + /// + public string TenantIdRegex { get; set; } = "^[a-zA-Z0-9\\-]+$"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptionsValidator.cs new file mode 100644 index 00000000..b6b25e92 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptionsValidator.cs @@ -0,0 +1,85 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options +{ + /// + /// Validates at application startup. + /// Ensures that tenant configuration values (regex patterns, defaults, + /// reserved identifiers, and requirement rules) are logically consistent + /// and safe to use before multi-tenant authentication begins. + /// + internal sealed class MultiTenantOptionsValidator : IValidateOptions + { + /// + /// Performs validation on the provided instance. + /// This method enforces: + /// - valid tenant id regex format, + /// - reserved tenant ids matching the regex, + /// - default tenant id consistency, + /// - requirement rules coherence. + /// + /// Optional configuration section name. + /// The options instance to validate. + /// + /// A indicating success or the + /// specific configuration error encountered. + /// + public ValidateOptionsResult Validate(string? name, MultiTenantOptions options) + { + // Multi-tenancy disabled → no validation needed + if (!options.Enabled) + return ValidateOptionsResult.Success; + + try + { + _ = new Regex(options.TenantIdRegex, RegexOptions.Compiled); + } + catch (Exception ex) + { + return ValidateOptionsResult.Fail( + $"Invalid TenantIdRegex '{options.TenantIdRegex}'. Regex error: {ex.Message}"); + } + + foreach (var reserved in options.ReservedTenantIds) + { + if (string.IsNullOrWhiteSpace(reserved)) + { + return ValidateOptionsResult.Fail( + "ReservedTenantIds cannot contain empty or whitespace values."); + } + + if (!Regex.IsMatch(reserved, options.TenantIdRegex)) + { + return ValidateOptionsResult.Fail( + $"Reserved tenant id '{reserved}' does not match TenantIdRegex '{options.TenantIdRegex}'."); + } + } + + if (options.DefaultTenantId != null) + { + if (string.IsNullOrWhiteSpace(options.DefaultTenantId)) + { + return ValidateOptionsResult.Fail("DefaultTenantId cannot be empty or whitespace."); + } + + if (!Regex.IsMatch(options.DefaultTenantId, options.TenantIdRegex)) + { + return ValidateOptionsResult.Fail($"DefaultTenantId '{options.DefaultTenantId}' does not match TenantIdRegex '{options.TenantIdRegex}'."); + } + + if (options.ReservedTenantIds.Contains(options.DefaultTenantId)) + { + return ValidateOptionsResult.Fail($"DefaultTenantId '{options.DefaultTenantId}' is listed in ReservedTenantIds."); + } + } + + if (options.RequireTenant && options.DefaultTenantId == null) + { + return ValidateOptionsResult.Fail("RequireTenant = true, but DefaultTenantId is null. Provide a default tenant id or disable RequireTenant."); + } + + return ValidateOptionsResult.Success; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/PkceOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/PkceOptions.cs new file mode 100644 index 00000000..ce1a7bd5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/PkceOptions.cs @@ -0,0 +1,17 @@ +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 PkceOptions + { + /// + /// 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; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/PkceOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/PkceOptionsValidator.cs new file mode 100644 index 00000000..6c95a63d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/PkceOptionsValidator.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options +{ + internal sealed class PkceOptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, PkceOptions 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/SessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/SessionOptions.cs new file mode 100644 index 00000000..696a197a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/SessionOptions.cs @@ -0,0 +1,80 @@ +namespace CodeBeam.UltimateAuth.Core.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 SessionOptions + { + /// + /// 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 set to zero, no hard cap + /// is applied. + /// + public TimeSpan MaxLifetime { get; set; } = TimeSpan.Zero; + + /// + /// When enabled, each refresh extends the session's expiration, + /// allowing continuous usage until MaxLifetime or idle rules apply. + /// + 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; } = TimeSpan.Zero; + + /// + /// Maximum number of device session chains a single user may have. + /// Set to zero to indicate no user-level chain limit. + /// + 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). + /// + public Dictionary? MaxChainsPerPlatform { get; set; } + + /// + /// Defines platform categories that map multiple platforms + /// into a single abstract group (e.g. mobile: [ "ios", "android", "tablet" ]). + /// + public Dictionary? PlatformCategories { get; set; } + + /// + /// Limits how many session chains can exist per platform category + /// (e.g. mobile = 1, desktop = 2). + /// + public Dictionary? MaxChainsPerCategory { get; set; } + + /// + /// Enables binding sessions to the user's IP address. + /// When enabled, IP mismatches can invalidate a session. + /// + public bool EnableIpBinding { get; set; } = false; + + /// + /// Enables binding sessions to the user's User-Agent header. + /// When enabled, UA mismatches can invalidate a session. + /// + public bool EnableUserAgentBinding { get; set; } = false; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/SessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/SessionOptionsValidator.cs new file mode 100644 index 00000000..c38d6424 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/SessionOptionsValidator.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options +{ + internal sealed class SessionOptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, SessionOptions 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/TokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/TokenOptions.cs new file mode 100644 index 00000000..8771aef3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/TokenOptions.cs @@ -0,0 +1,57 @@ +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 TokenOptions + { + /// + /// 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; + + /// + /// 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; + + /// + /// Symmetric key used to sign JWT access tokens. + /// Must be long and cryptographically strong. + /// + public string SigningKey { get; set; } = string.Empty!; + + /// + /// 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"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/TokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/TokenOptionsValidator.cs new file mode 100644 index 00000000..563c4c08 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/TokenOptionsValidator.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options +{ + internal sealed class TokenOptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, TokenOptions 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.RefreshTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.RefreshTokenLifetime must be greater than zero."); + + if (options.RefreshTokenLifetime <= options.AccessTokenLifetime) + errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); + + if (options.IssueJwt) + { + if (string.IsNullOrWhiteSpace(options.SigningKey)) + { + errors.Add("Token.SigningKey must not be empty when IssueJwt = true."); + } + else if (options.SigningKey.Length < 32) // 256-bit minimum + { + errors.Add("Token.SigningKey must be at least 32 characters long (256-bit entropy)."); + } + + if (string.IsNullOrWhiteSpace(options.Issuer)) // TODO: Min 3 chars + errors.Add("Token.Issuer must not be empty when IssueJwt = true."); + + if (string.IsNullOrWhiteSpace(options.Audience)) + errors.Add("Token.Audience must not be empty when IssueJwt = true."); + } + + if (options.IssueOpaque) + { + if (options.OpaqueIdBytes < 16) + errors.Add("Token.OpaqueIdBytes must be at least 16 (128-bit entropy)."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptions.cs new file mode 100644 index 00000000..fd015b4d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptions.cs @@ -0,0 +1,58 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +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 UltimateAuthOptions + { + /// + /// Configuration settings for interactive login flows, + /// including lockout thresholds and failed-attempt policies. + /// + public LoginOptions Login { get; set; } = new(); + + /// + /// Settings that control session creation, refresh behavior, + /// sliding expiration, idle timeouts, device limits, and chain rules. + /// + public SessionOptions Session { get; set; } = new(); + + /// + /// Token issuance configuration, including JWT and opaque token + /// generation, lifetimes, signing keys, and audience/issuer values. + /// + public TokenOptions Token { get; set; } = new(); + + /// + /// PKCE (Proof Key for Code Exchange) configuration used for + /// browser-based login flows and WASM authentication. + /// + public PkceOptions Pkce { get; set; } = new(); + + /// + /// Event hooks raised during authentication lifecycle events + /// such as login, logout, session creation, refresh, or revocation. + /// + public UAuthEvents UAuthEvents { get; set; } = new(); + + /// + /// Multi-tenancy configuration controlling how tenants are resolved, + /// validated, and optionally enforced. + /// + public MultiTenantOptions MultiTenantOptions { get; set; } = new(); + + /// + /// Provides converters used to normalize and serialize TUserId + /// across the system (sessions, stores, tokens, logging). + /// + public IUserIdConverterResolver? UserIdConverters { get; set; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptionsValidator.cs new file mode 100644 index 00000000..e3134564 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptionsValidator.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options +{ + internal sealed class UltimateAuthOptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, UltimateAuthOptions 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 (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/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/Utilities/Base64Url.cs b/src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs new file mode 100644 index 00000000..8b120f0c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs @@ -0,0 +1,49 @@ +namespace CodeBeam.UltimateAuth.Core.Utilities +{ + /// + /// 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/Utilities/RandomIdGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Utilities/RandomIdGenerator.cs new file mode 100644 index 00000000..5c63d26d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Utilities/RandomIdGenerator.cs @@ -0,0 +1,54 @@ +using System.Security.Cryptography; + +namespace CodeBeam.UltimateAuth.Core.Utilities +{ + /// + /// Provides cryptographically secure random ID generation. + /// + /// Produces opaque identifiers suitable for session IDs, PKCE codes, + /// refresh tokens, and other entropy-critical values. Output is encoded + /// using Base64Url for safe transport in URLs and headers. + /// + public static class RandomIdGenerator + { + /// + /// Generates a cryptographically secure random identifier with the + /// specified byte length and returns it as a URL-safe Base64 string. + /// + /// The number of random bytes to generate. + /// A URL-safe Base64 encoded random value. + /// + /// Thrown when is zero or negative. + /// + public static string Generate(int byteLength) + { + if (byteLength <= 0) + throw new ArgumentOutOfRangeException(nameof(byteLength)); + + var buffer = new byte[byteLength]; + RandomNumberGenerator.Fill(buffer); + + return Base64Url.Encode(buffer); + } + + /// + /// Generates a cryptographically secure random byte array with the + /// specified length. + /// + /// The number of bytes to generate. + /// A randomly filled byte array. + /// + /// Thrown when is zero or negative. + /// + public static byte[] GenerateBytes(int byteLength) + { + if (byteLength <= 0) + throw new ArgumentOutOfRangeException(nameof(byteLength)); + + var buffer = new byte[byteLength]; + RandomNumberGenerator.Fill(buffer); + return buffer; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs new file mode 100644 index 00000000..e0ebae56 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs @@ -0,0 +1,83 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Core.Utilities +{ + /// + /// 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 ToString(TUserId id) + { + return id switch + { + int v => v.ToString(CultureInfo.InvariantCulture), + long v => v.ToString(CultureInfo.InvariantCulture), + Guid v => v.ToString("N"), + string v => v, + _ => JsonSerializer.Serialize(id) + }; + } + + /// + /// 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(ToString(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. + /// + /// Thrown when deserialization of complex types fails. + /// + public TUserId FromString(string value) + { + return typeof(TUserId) switch + { + 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), + Type t when t == typeof(Guid) => (TUserId)(object)Guid.Parse(value), + Type t when t == typeof(string) => (TUserId)(object)value, + + _ => JsonSerializer.Deserialize(value) + ?? throw new UAuthInternalException("Cannot deserialize TUserId") + }; + } + + /// + /// 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)); + + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs new file mode 100644 index 00000000..8e2f0797 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Core.Utilities +{ + /// + /// 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() + { + var converter = _sp.GetService>(); + if (converter != null) + return converter; + + return new UAuthUserIdConverter(); + } + + } +} From 24aef691241e6c9adb60f11a2e9197d9df01dc5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 11 Dec 2025 00:20:19 +0300 Subject: [PATCH 08/50] Add CI workflow --- .github/workflows/ultimateauth-ci.yml | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/ultimateauth-ci.yml diff --git a/.github/workflows/ultimateauth-ci.yml b/.github/workflows/ultimateauth-ci.yml new file mode 100644 index 00000000..17a24e61 --- /dev/null +++ b/.github/workflows/ultimateauth-ci.yml @@ -0,0 +1,43 @@ +name: UltimateAuth CI + +on: + pull_request: + branches: + - dev + push: + branches: + - dev + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + + strategy: + 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: 🧪 Run tests + run: dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" + + - name: ⬆ Upload code coverage + uses: actions/upload-artifact@v4 + with: + name: code-coverage + path: '**/coverage.cobertura.xml' + if-no-files-found: ignore From 3e554312364855feffa39511a2464b160e0bc247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 14 Dec 2025 00:58:05 +0300 Subject: [PATCH 09/50] First Implementation of Server Project (#3) * First Implementation of Server Project * Add MultiTenancy * Checking Architecture & Fixes --- .../Issuers/IJwtTokenGenerator.cs | 13 + .../Issuers/IOpaqueTokenGenerator.cs | 11 + .../Abstractions/Issuers/ISessionIssuer.cs | 13 + .../Abstractions/Issuers/ITokenHasher.cs | 12 + .../Abstractions/Issuers/ITokenIssuer.cs | 14 ++ .../Stores/DefaultSessionStoreFactory.cs | 6 +- .../Abstractions/Stores/ISessionStore.cs | 146 +++-------- .../Stores/ISessionStoreFactory.cs | 6 +- .../Stores/ISessionStoreKernel.cs | 140 +++++++++++ .../Abstractions/Stores/ITokenStore.cs | 75 ++++++ .../CodeBeam.UltimateAuth.Core.csproj | 2 +- .../Contexts/Issued/IssuedAccessToken.cs | 29 +++ .../Contexts/Issued/IssuedRefreshToken.cs | 24 ++ .../Contexts/Issued/IssuedSession.cs | 27 ++ .../Contexts/SessionIssueContext.cs | 23 ++ .../Contexts/SessionStoreContext.cs | 43 ++++ .../Contexts/TokenIssueContext.cs | 16 ++ .../Contexts/UserContext.cs | 14 ++ .../Domain/Session/AuthSessionId.cs | 19 +- .../Domain/Session/ISession.cs | 2 +- .../Domain/Session/ISessionChain.cs | 5 +- .../Domain/Session/ISessionRoot.cs | 12 +- .../Domain/Session/SessionMetadata.cs | 8 + .../Domain/Session/UAuthSession.cs | 112 +++++++++ .../Domain/Session/UAuthSessionChain.cs | 94 +++++++ .../Domain/Session/UAuthSessionRoot.cs | 64 +++++ .../Domain/Token/UAuthJwtTokenDescriptor.cs | 24 ++ .../Enums/UAuthMode.cs | 43 ++++ ...UltimateAuthServiceCollectionExtensions.cs | 26 +- .../UltimateAuthSessionStoreExtensions.cs | 14 +- .../Internal/UAuthSession.cs | 71 ------ .../Internal/UAuthSessionChain.cs | 76 ------ .../Internal/UAuthSessionRoot.cs | 95 ------- .../Internal/UAuthSessionService.cs | 170 ------------- .../MultiTenancy/CompositeTenantResolver.cs | 6 +- .../MultiTenancy/FixedTenantResolver.cs | 2 +- .../MultiTenancy/HeaderTenantResolver.cs | 2 +- .../MultiTenancy/HostTenantResolver.cs | 38 ++- ...TenantResolver.cs => ITenantIdResolver.cs} | 2 +- .../MultiTenancy/PathTenantResolver.cs | 2 +- .../MultiTenancy/TenantResolutionContext.cs | 29 +++ .../MultiTenancy/TenantValidation.cs | 28 +++ .../MultiTenancy/UAuthTenantContext.cs | 23 ++ .../{LoginOptions.cs => UAuthLoginOptions.cs} | 2 +- ...tOptions.cs => UAuthMultiTenantOptions.cs} | 15 +- ...cs => UAuthMultiTenantOptionsValidator.cs} | 8 +- ...UltimateAuthOptions.cs => UAuthOptions.cs} | 12 +- ...sValidator.cs => UAuthOptionsValidator.cs} | 4 +- .../{PkceOptions.cs => UAuthPkceOptions.cs} | 2 +- ...idator.cs => UAuthPkceOptionsValidator.cs} | 4 +- ...ssionOptions.cs => UAuthSessionOptions.cs} | 8 +- ...tor.cs => UAuthSessionOptionsValidator.cs} | 4 +- .../{TokenOptions.cs => UAuthTokenOptions.cs} | 8 +- ...dator.cs => UAuthTokenOptionsValidator.cs} | 4 +- .../CodeBeam.UltimateAuth.Server.csproj | 12 + .../Abstractions/ILoginEndpointHandler.cs | 9 + .../Abstractions/ILogoutEndpointHandler.cs | 9 + .../Abstractions/IPkceEndpointHandler.cs | 11 + .../Abstractions/IReauthEndpointHandler.cs | 9 + .../Abstractions/ISessionManagementHandler.cs | 12 + .../ISessionRefreshEndpointHandler.cs | 9 + .../Abstractions/ITokenEndpointHandler.cs | 12 + .../Abstractions/IUserInfoEndpointHandler.cs | 11 + .../Endpoints/DefaultLoginEndpointHandler.cs | 12 + .../Endpoints/DefaultPkceEndpointHandler.cs | 16 ++ .../Endpoints/EndpointEnablement.cs | 8 + .../Endpoints/UAuthEndpointDefaults.cs | 15 ++ .../Endpoints/UAuthEndpointDefaultsMap.cs | 56 +++++ .../Endpoints/UAuthEndpointRegistrar.cs | 109 ++++++++ .../HttpContextSessionExtensions.cs | 24 ++ .../Extensions/HttpContextTenantExtensions.cs | 27 ++ .../TenantResolutionContextExtensions.cs | 25 ++ .../UAuthApplicationBuilderExtensions.cs | 19 ++ .../UAuthServerServiceCollectionExtensions.cs | 105 ++++++++ .../Issuers/UAuthSessionIssuer.cs | 70 ++++++ .../Issuers/UAuthTokenIssuer.cs | 126 ++++++++++ .../SessionResolutionMiddleware.cs | 38 +++ .../Middlewares/TenantMiddleware.cs | 53 ++++ .../Middlewares/UserMiddleware.cs | 27 ++ .../MultiTenancy/DomainTenantAdapter.cs | 34 +++ .../MultiTenancy/HeaderTenantAdapter.cs | 34 +++ .../MultiTenancy/ITenantResolver.cs | 11 + .../MultiTenancy/RouteTenantAdapter.cs | 32 +++ .../TenantResolutionContextFactory.cs | 31 +++ .../MultiTenancy/UAuthTenantContextFactory.cs | 22 ++ .../MultiTenancy/UAuthTenantResolver.cs | 75 ++++++ .../Options/.gitkeep | 1 - .../Options/UAuthServerOptions.cs | 105 ++++++++ .../Options/UAuthServerOptionsValidator.cs | 75 ++++++ .../Options/UAuthSessionResolutionOptions.cs | 24 ++ .../Session/.gitkeep | 1 - .../Sessions/BearerSessionIdResolver.cs | 24 ++ .../Sessions/CompositeSessionIdResolver.cs | 27 ++ .../Sessions/CookieSessionIdResolver.cs | 26 ++ .../Sessions/HeaderSessionIdResolver.cs | 27 ++ .../Sessions/ISessionIdResolver.cs | 10 + .../Sessions/ISessionOrchestrator.cs | 34 +++ .../Sessions/QuerySessionIdResolver.cs | 27 ++ .../Sessions/SessionContext.cs | 31 +++ .../Sessions/UAuthSessionIdResolver.cs | 44 ++++ .../Sessions/UAuthSessionOrchestrator.cs | 235 ++++++++++++++++++ .../Stores/.gitkeep | 1 - .../Stores/UAuthSessionStoreFactory.cs | 36 +++ .../Users/IUserAccessor.cs | 9 + .../Users/UAuthUserAccessor.cs | 64 +++++ 105 files changed, 2883 insertions(+), 633 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IJwtTokenGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenHasher.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedSession.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Enums/UAuthMode.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Internal/UAuthSession.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionChain.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionRoot.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionService.cs rename src/CodeBeam.UltimateAuth.Core/MultiTenancy/{ITenantResolver.cs => ITenantIdResolver.cs} (93%) create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantValidation.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs rename src/CodeBeam.UltimateAuth.Core/Options/{LoginOptions.cs => UAuthLoginOptions.cs} (94%) rename src/CodeBeam.UltimateAuth.Core/Options/{MultiTenantOptions.cs => UAuthMultiTenantOptions.cs} (79%) rename src/CodeBeam.UltimateAuth.Core/Options/{MultiTenantOptionsValidator.cs => UAuthMultiTenantOptionsValidator.cs} (89%) rename src/CodeBeam.UltimateAuth.Core/Options/{UltimateAuthOptions.cs => UAuthOptions.cs} (84%) rename src/CodeBeam.UltimateAuth.Core/Options/{UltimateAuthOptionsValidator.cs => UAuthOptionsValidator.cs} (88%) rename src/CodeBeam.UltimateAuth.Core/Options/{PkceOptions.cs => UAuthPkceOptions.cs} (93%) rename src/CodeBeam.UltimateAuth.Core/Options/{PkceOptionsValidator.cs => UAuthPkceOptionsValidator.cs} (73%) rename src/CodeBeam.UltimateAuth.Core/Options/{SessionOptions.cs => UAuthSessionOptions.cs} (92%) rename src/CodeBeam.UltimateAuth.Core/Options/{SessionOptionsValidator.cs => UAuthSessionOptionsValidator.cs} (95%) rename src/CodeBeam.UltimateAuth.Core/Options/{TokenOptions.cs => UAuthTokenOptions.cs} (88%) rename src/CodeBeam.UltimateAuth.Core/Options/{TokenOptionsValidator.cs => UAuthTokenOptionsValidator.cs} (91%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionRefreshEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/EndpointEnablement.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaults.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/TenantResolutionContextExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Options/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Session/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Stores/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs 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..0fe74224 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IJwtTokenGenerator.cs @@ -0,0 +1,13 @@ +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/IOpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs new file mode 100644 index 00000000..0c49dcfc --- /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(int byteLength = 32); + } +} 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..f19db3eb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Issues and manages authentication sessions. + /// + public interface ISessionIssuer + { + Task> IssueAsync(SessionIssueContext context, UAuthSessionChain chain, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenHasher.cs new file mode 100644 index 00000000..ebf44998 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenHasher.cs @@ -0,0 +1,12 @@ +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 plaintext, string hash); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs new file mode 100644 index 00000000..948bf3b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contexts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Issues access and refresh tokens according to the active auth mode. + /// Does not perform persistence or validation. + /// + public interface ITokenIssuer + { + Task IssueAccessTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs index 8ff089de..543ec2f0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs @@ -10,11 +10,11 @@ public sealed class DefaultSessionStoreFactory : ISessionStoreFactory /// The type used to uniquely identify the user. /// Never returns; always throws. /// Thrown when no session store implementation has been configured. - public ISessionStore Create(string? tenantId) + public ISessionStoreKernel Create(string? tenantId) { throw new InvalidOperationException( - "No ISessionStore implementation registered. " + - "Call AddUltimateAuthServer().AddSessionStore() to provide a real implementation." + "No session store has been configured." + + "Call AddUltimateAuthServer().AddSessionStore(...) to register one." ); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 3729d719..e25ba9c1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -1,140 +1,58 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Core.Abstractions { /// - /// Defines the low-level persistence operations for sessions, session chains, and session roots in a multi-tenant or single-tenant environment. - /// Store implementations provide durable and atomic data access. + /// High-level session store abstraction used by UltimateAuth. + /// Encapsulates session, chain, and root orchestration. /// public interface ISessionStore { /// - /// Retrieves a session by its identifier within the given tenant context. + /// Retrieves an active session by id. /// - /// The tenant identifier, or null for single-tenant mode. - /// The session identifier. - /// The session instance or null if not found. - Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId); + Task?> GetSessionAsync( + string? tenantId, + AuthSessionId sessionId); /// - /// Persists a new session or updates an existing one within the tenant scope. - /// Implementations must ensure atomic writes. + /// Creates a new session and associates it with the appropriate chain and root. /// - /// The tenant identifier, or null. - /// The session to persist. - Task SaveSessionAsync(string? tenantId, ISession session); + Task CreateSessionAsync( + IssuedSession issuedSession, + SessionStoreContext context); /// - /// Marks the specified session as revoked, preventing future authentication. - /// Revocation timestamp must be stored reliably. + /// Refreshes (rotates) the active session within its chain. /// - /// The tenant identifier, or null. - /// The session identifier. - /// The UTC timestamp of revocation. - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); - - /// - /// Returns all sessions belonging to the specified chain, ordered according to store implementation rules. - /// - /// The tenant identifier, or null. - /// The chain identifier. - /// A read-only list of sessions. - Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId); - - /// - /// Retrieves a session chain by identifier. Returns null if the chain does not exist in the provided tenant context. - /// - /// The tenant identifier, or null. - /// The chain identifier. - /// The chain or null. - Task?> GetChainAsync(string? tenantId, ChainId chainId); - - /// - /// Inserts a new session chain into the store. Implementations must ensure consistency with the related sessions and session root. - /// - /// The tenant identifier, or null. - /// The chain to save. - Task SaveChainAsync(string? tenantId, ISessionChain chain); - - /// - /// Updates an existing session chain, typically after session rotation or revocation. Implementations must preserve atomicity. - /// - /// The tenant identifier, or null. - /// The updated session chain. - Task UpdateChainAsync(string? tenantId, ISessionChain chain); - - /// - /// Marks the entire session chain as revoked, invalidating all associated sessions for the device or app family. - /// - /// The tenant identifier, or null. - /// The chain to revoke. - /// The UTC timestamp of revocation. - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); - - /// - /// Retrieves the active session identifier for the specified chain. - /// This is typically an O(1) lookup and used for session rotation. - /// - /// The tenant identifier, or null. - /// The chain whose active session is requested. - /// The active session identifier or null. - Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId); - - /// - /// Sets or replaces the active session identifier for the specified chain. - /// Must be atomic to prevent race conditions during refresh. - /// - /// The tenant identifier, or null. - /// The chain whose active session is being set. - /// The new active session identifier. - Task SetActiveSessionIdAsync(string? tenantId, ChainId chainId, AuthSessionId sessionId); - - /// - /// Retrieves all session chains belonging to the specified user within the tenant scope. - /// - /// The tenant identifier, or null. - /// The user whose chains are being retrieved. - /// A read-only list of session chains. - Task>> GetChainsByUserAsync(string? tenantId, TUserId userId); + Task RotateSessionAsync( + AuthSessionId currentSessionId, + IssuedSession newSession, + SessionStoreContext context); /// - /// Retrieves the session root for the user, which represents the full set of chains and their associated security metadata. - /// Returns null if the root does not exist. + /// Revokes a single session. /// - /// The tenant identifier, or null. - /// The user identifier. - /// The session root or null. - Task?> GetSessionRootAsync(string? tenantId, TUserId userId); + Task RevokeSessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTime at); /// - /// Persists a session root structure, usually after chain creation, rotation, or security operations. + /// Revokes all sessions for a specific user (all devices). /// - /// The tenant identifier, or null. - /// The session root to save. - Task SaveSessionRootAsync(string? tenantId, ISessionRoot root); + Task RevokeAllSessionsAsync( + string? tenantId, + TUserId userId, + DateTime at); /// - /// Revokes the session root, invalidating all chains and sessions belonging to the specified user in the tenant scope. + /// Revokes all sessions within a specific chain (single device). /// - /// The tenant identifier, or null. - /// The user whose root should be revoked. - /// The UTC timestamp of revocation. - Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTime at); - - /// - /// Removes expired sessions from the store while leaving chains and session roots intact. Cleanup strategy is determined by the store implementation. - /// - /// The tenant identifier, or null. - /// The current UTC timestamp. - Task DeleteExpiredSessionsAsync(string? tenantId, DateTime now); - - /// - /// Retrieves the chain identifier associated with the specified session. - /// - /// The tenant identifier, or null. - /// The session identifier. - /// The chain identifier or null. - Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId); + Task RevokeChainAsync( + string? tenantId, + ChainId chainId, + DateTime at); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs index 0a18fe12..a49165d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs @@ -3,7 +3,7 @@ /// /// 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. + /// Implementations typically resolve concrete types from the dependency injection container. /// public interface ISessionStoreFactory { @@ -15,11 +15,11 @@ public interface ISessionStoreFactory /// The tenant identifier for multi-tenant environments, or null for single-tenant mode. /// /// - /// An implementation able to perform session persistence operations. + /// An implementation able to perform session persistence operations. /// /// /// Thrown if no compatible session store implementation is registered. /// - ISessionStore Create(string? tenantId); + ISessionStoreKernel Create(string? tenantId); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs new file mode 100644 index 00000000..9d8763b5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs @@ -0,0 +1,140 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Defines the low-level persistence operations for sessions, session chains, and session roots in a multi-tenant or single-tenant environment. + /// Store implementations provide durable and atomic data access. + /// + public interface ISessionStoreKernel + { + /// + /// Retrieves a session by its identifier within the given tenant context. + /// + /// The tenant identifier, or null for single-tenant mode. + /// The session identifier. + /// The session instance or null if not found. + Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId); + + /// + /// Persists a new session or updates an existing one within the tenant scope. + /// Implementations must ensure atomic writes. + /// + /// The tenant identifier, or null. + /// The session to persist. + Task SaveSessionAsync(string? tenantId, ISession session); + + /// + /// Marks the specified session as revoked, preventing future authentication. + /// Revocation timestamp must be stored reliably. + /// + /// The tenant identifier, or null. + /// The session identifier. + /// The UTC timestamp of revocation. + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + + /// + /// Returns all sessions belonging to the specified chain, ordered according to store implementation rules. + /// + /// The tenant identifier, or null. + /// The chain identifier. + /// A read-only list of sessions. + Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId); + + /// + /// Retrieves a session chain by identifier. Returns null if the chain does not exist in the provided tenant context. + /// + /// The tenant identifier, or null. + /// The chain identifier. + /// The chain or null. + Task?> GetChainAsync(string? tenantId, ChainId chainId); + + /// + /// Inserts a new session chain into the store. Implementations must ensure consistency with the related sessions and session root. + /// + /// The tenant identifier, or null. + /// The chain to save. + Task SaveChainAsync(string? tenantId, ISessionChain chain); + + /// + /// Updates an existing session chain, typically after session rotation or revocation. Implementations must preserve atomicity. + /// + /// The tenant identifier, or null. + /// The updated session chain. + Task UpdateChainAsync(string? tenantId, ISessionChain chain); + + /// + /// Marks the entire session chain as revoked, invalidating all associated sessions for the device or app family. + /// + /// The tenant identifier, or null. + /// The chain to revoke. + /// The UTC timestamp of revocation. + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); + + /// + /// Retrieves the active session identifier for the specified chain. + /// This is typically an O(1) lookup and used for session rotation. + /// + /// The tenant identifier, or null. + /// The chain whose active session is requested. + /// The active session identifier or null. + Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId); + + /// + /// Sets or replaces the active session identifier for the specified chain. + /// Must be atomic to prevent race conditions during refresh. + /// + /// The tenant identifier, or null. + /// The chain whose active session is being set. + /// The new active session identifier. + Task SetActiveSessionIdAsync(string? tenantId, ChainId chainId, AuthSessionId sessionId); + + /// + /// Retrieves all session chains belonging to the specified user within the tenant scope. + /// + /// The tenant identifier, or null. + /// The user whose chains are being retrieved. + /// A read-only list of session chains. + Task>> GetChainsByUserAsync(string? tenantId, TUserId userId); + + /// + /// Retrieves the session root for the user, which represents the full set of chains and their associated security metadata. + /// Returns null if the root does not exist. + /// + /// The tenant identifier, or null. + /// The user identifier. + /// The session root or null. + Task?> GetSessionRootAsync(string? tenantId, TUserId userId); + + /// + /// Persists a session root structure, usually after chain creation, rotation, or security operations. + /// + /// The tenant identifier, or null. + /// The session root to save. + Task SaveSessionRootAsync(string? tenantId, ISessionRoot root); + + /// + /// Revokes the session root, invalidating all chains and sessions belonging to the specified user in the tenant scope. + /// + /// The tenant identifier, or null. + /// The user whose root should be revoked. + /// The UTC timestamp of revocation. + Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTime at); + + /// + /// Removes expired sessions from the store while leaving chains and session roots intact. Cleanup strategy is determined by the store implementation. + /// + /// The tenant identifier, or null. + /// The current UTC timestamp. + Task DeleteExpiredSessionsAsync(string? tenantId, DateTime now); + + /// + /// Retrieves the chain identifier associated with the specified session. + /// + /// The tenant identifier, or null. + /// The session identifier. + /// The chain identifier or null. + Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs new file mode 100644 index 00000000..ee116acc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs @@ -0,0 +1,75 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Provides persistence and validation support for issued tokens, + /// including refresh tokens and optional access token identifiers (jti). + /// + public interface ITokenStore + { + /// + /// Persists a refresh token hash associated with a session. + /// + Task StoreRefreshTokenAsync( + string? tenantId, + TUserId userId, + AuthSessionId sessionId, + string refreshTokenHash, + DateTimeOffset expiresAt); + + /// + /// Validates a provided refresh token against the stored hash. + /// Returns true if valid and not expired or revoked. + /// + Task ValidateRefreshTokenAsync( + string? tenantId, + TUserId userId, + AuthSessionId sessionId, + string providedRefreshToken); + + /// + /// Revokes the refresh token associated with the specified session. + /// + Task RevokeRefreshTokenAsync( + string? tenantId, + AuthSessionId sessionId, + DateTimeOffset at); + + /// + /// Revokes all refresh tokens belonging to the user. + /// + Task RevokeAllRefreshTokensAsync( + string? tenantId, + TUserId userId, + DateTimeOffset at); + + // ------------------------------------------------------------ + // ACCESS TOKEN IDENTIFIERS (OPTIONAL) + // ------------------------------------------------------------ + + /// + /// Stores a JWT ID (jti) for replay detection or revocation. + /// Implementations may ignore this if not supported. + /// + Task StoreTokenIdAsync( + string? tenantId, + string jti, + DateTimeOffset expiresAt); + + /// + /// Determines whether the specified token identifier has been revoked. + /// + Task IsTokenIdRevokedAsync( + string? tenantId, + string jti); + + /// + /// Revokes a token identifier, preventing further usage. + /// + Task RevokeTokenIdAsync( + string? tenantId, + string jti, + DateTimeOffset at); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj index 34c42190..488b584d 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs new file mode 100644 index 00000000..32d37e6b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Core.Contexts +{ + /// + /// Represents an issued access token (JWT or opaque). + /// + public sealed class IssuedAccessToken + { + /// + /// The actual token value sent to the client. + /// + public required string Token { get; init; } + + /// + /// Token type: "jwt" or "opaque". + /// Used for diagnostics and middleware behavior. + /// + public required string TokenType { 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; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs new file mode 100644 index 00000000..1f648048 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.Core.Contexts +{ + /// + /// Represents an issued refresh token. + /// Always opaque and hashed at rest. + /// + public sealed class IssuedRefreshToken + { + /// + /// 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/Contexts/Issued/IssuedSession.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedSession.cs new file mode 100644 index 00000000..157663a4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedSession.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contexts +{ + /// + /// Represents the result of a session issuance operation. + /// + public sealed class IssuedSession + { + /// + /// The issued domain session. + /// + public required ISession 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/Contexts/SessionIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs new file mode 100644 index 00000000..d75473c2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contexts +{ + /// + /// Represents the context in which a session is issued + /// (login, refresh, reauthentication). + /// + public sealed class SessionIssueContext + { + public required TUserId UserId { get; init; } + public string? TenantId { get; init; } + + public required long SecurityVersion { get; init; } + + public DeviceInfo Device { get; init; } + public IReadOnlyDictionary? ClaimsSnapshot { get; init; } + + public DateTime Now { get; init; } = DateTime.UtcNow; + + public ChainId? ChainId { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs new file mode 100644 index 00000000..fa5a4267 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs @@ -0,0 +1,43 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contexts +{ + /// + /// Context information required by the session store when + /// creating or rotating sessions. + /// + public sealed class SessionStoreContext + { + /// + /// The authenticated user identifier. + /// + public required TUserId UserId { get; init; } + + /// + /// The tenant identifier, if multi-tenancy is enabled. + /// + public string? TenantId { get; init; } + + /// + /// Optional chain identifier. + /// If null, a new chain should be created. + /// + public ChainId? 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 DeviceInfo? DeviceInfo { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs new file mode 100644 index 00000000..782d6a40 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs @@ -0,0 +1,16 @@ +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Contexts +{ + public sealed class TokenIssueContext + { + public required string UserId { get; init; } + public required string TenantId { get; init; } + + public IReadOnlyCollection Claims { get; init; } = Array.Empty(); + + public string? SessionId { get; init; } + + public DateTimeOffset IssuedAt { get; init; } = DateTimeOffset.UtcNow; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs new file mode 100644 index 00000000..34f30a24 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Contexts +{ + public sealed class UserContext + { + public TUserId? UserId { get; init; } + public IUser? User { get; init; } + + public bool IsAuthenticated => UserId is not null; + + public static UserContext Anonymous() => new(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index f5a8ba2e..70b73721 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -10,21 +10,18 @@ /// Initializes a new using the specified GUID value. /// /// The underlying GUID representing the session identifier. - public AuthSessionId(Guid value) + public AuthSessionId(string value) { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("SessionId cannot be empty.", nameof(value)); + Value = value; } /// /// Gets the underlying GUID value of the session identifier. /// - public Guid Value { get; } - - /// - /// Generates a new session identifier using a newly created GUID. - /// - /// A new instance. - public static AuthSessionId New() => new AuthSessionId(Guid.NewGuid()); + public string Value { get; } /// /// Determines whether the specified is equal to the current instance. @@ -43,19 +40,19 @@ public AuthSessionId(Guid value) /// /// Returns a hash code based on the underlying GUID value. /// - public override int GetHashCode() => Value.GetHashCode(); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); /// /// Returns the string representation of the underlying GUID value. /// /// The GUID as a string. - public override string ToString() => Value.ToString(); + public override string ToString() => Value; /// /// Converts the to its underlying . /// /// The session identifier. /// The underlying GUID value. - public static implicit operator Guid(AuthSessionId id) => id.Value; + public static implicit operator string(AuthSessionId id) => id.Value; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs index 878d6b23..349ae7eb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -31,7 +31,7 @@ public interface ISession /// Gets the timestamp of the last successful usage. /// Used when evaluating sliding expiration policies. /// - DateTime LastSeenAt { get; } + DateTime? LastSeenAt { get; } /// /// Gets a value indicating whether this session has been explicitly revoked. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs index b7034df1..e408b171 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs @@ -38,10 +38,9 @@ public interface ISessionChain IReadOnlyDictionary? ClaimsSnapshot { get; } /// - /// Gets the list of all rotated sessions created within this chain. - /// The newest session is always considered the active one. + /// Gets the identifier of the currently active authentication session, if one exists. /// - IReadOnlyList> Sessions { get; } + AuthSessionId? ActiveSessionId { get; } /// /// Gets a value indicating whether this chain has been revoked. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs index c3d3baf5..afc7d14d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs @@ -7,18 +7,18 @@ /// public interface ISessionRoot { - /// - /// Gets the identifier of the user who owns this session root. - /// Each user has one root per tenant. - /// - TUserId UserId { get; } - /// /// Gets the tenant identifier associated with this session root. /// Used to isolate authentication domains in multi-tenant systems. /// string? TenantId { get; } + /// + /// Gets the identifier of the user who owns this session root. + /// Each user has one root per tenant. + /// + TUserId UserId { get; } + /// /// Gets a value indicating whether the entire session root is revoked. /// When true, all chains and sessions belonging to this root are invalid, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs index a4963810..20fe7fe1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs @@ -7,6 +7,14 @@ /// 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. 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..07dab4e4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -0,0 +1,112 @@ +namespace CodeBeam.UltimateAuth.Core.Domain.Session +{ + public sealed class UAuthSession : ISession + { + public AuthSessionId SessionId { get; } + public string? TenantId { get; } + public TUserId UserId { get; } + public DateTime CreatedAt { get; } + public DateTime ExpiresAt { get; } + public DateTime? LastSeenAt { get; } + public bool IsRevoked { get; } + public DateTime? RevokedAt { get; } + public long SecurityVersionAtCreation { get; } + public DeviceInfo Device { get; } + public SessionMetadata Metadata { get; } + + private UAuthSession( + AuthSessionId sessionId, + string? tenantId, + TUserId userId, + DateTime createdAt, + DateTime expiresAt, + DateTime? lastSeenAt, + bool isRevoked, + DateTime? revokedAt, + long securityVersionAtCreation, + DeviceInfo device, + SessionMetadata metadata) + { + SessionId = sessionId; + TenantId = tenantId; + UserId = userId; + CreatedAt = createdAt; + ExpiresAt = expiresAt; + LastSeenAt = lastSeenAt; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + SecurityVersionAtCreation = securityVersionAtCreation; + Device = device; + Metadata = metadata; + } + + public static UAuthSession Create( + AuthSessionId sessionId, + string? tenantId, + TUserId userId, + DateTime now, + DateTime expiresAt, + long securityVersion, + DeviceInfo device, + SessionMetadata metadata) + { + return new UAuthSession( + sessionId, + tenantId, + userId, + createdAt: now, + expiresAt: expiresAt, + lastSeenAt: now, + isRevoked: false, + revokedAt: null, + securityVersionAtCreation: securityVersion, + device: device, + metadata: metadata + ); + } + + public UAuthSession WithLastSeen(DateTime now) + { + return new UAuthSession( + SessionId, + TenantId, + UserId, + CreatedAt, + ExpiresAt, + lastSeenAt: now, + IsRevoked, + RevokedAt, + SecurityVersionAtCreation, + Device, + Metadata + ); + } + + public UAuthSession Revoke(DateTime at) + { + if (IsRevoked) return this; + + return new UAuthSession( + SessionId, + TenantId, + UserId, + CreatedAt, + ExpiresAt, + LastSeenAt, + isRevoked: true, + revokedAt: at, + SecurityVersionAtCreation, + Device, + Metadata + ); + } + + public SessionState GetState(DateTime now) + { + if (IsRevoked) return SessionState.Revoked; + if (now >= ExpiresAt) return SessionState.Expired; + return SessionState.Active; + } + } + +} 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..c7758776 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -0,0 +1,94 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public sealed class UAuthSessionChain : ISessionChain + { + public ChainId ChainId { get; } + public string? TenantId { get; } + public TUserId UserId { get; } + public int RotationCount { get; } + public long SecurityVersionAtCreation { get; } + public IReadOnlyDictionary? ClaimsSnapshot { get; } + public AuthSessionId? ActiveSessionId { get; } + public bool IsRevoked { get; } + public DateTime? RevokedAt { get; } + + private UAuthSessionChain( + ChainId chainId, + string? tenantId, + TUserId userId, + int rotationCount, + long securityVersionAtCreation, + IReadOnlyDictionary? claimsSnapshot, + AuthSessionId? activeSessionId, + bool isRevoked, + DateTime? revokedAt) + { + ChainId = chainId; + TenantId = tenantId; + UserId = userId; + RotationCount = rotationCount; + SecurityVersionAtCreation = securityVersionAtCreation; + ClaimsSnapshot = claimsSnapshot; + ActiveSessionId = activeSessionId; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + } + + public static UAuthSessionChain Create( + ChainId chainId, + string? tenantId, + TUserId userId, + long securityVersion, + IReadOnlyDictionary? claimsSnapshot = null) + { + return new UAuthSessionChain( + chainId, + tenantId, + userId, + rotationCount: 0, + securityVersionAtCreation: securityVersion, + claimsSnapshot: claimsSnapshot, + activeSessionId: null, + isRevoked: false, + revokedAt: null + ); + } + + public UAuthSessionChain ActivateSession(AuthSessionId sessionId) + { + if (IsRevoked) + return this; + + return new UAuthSessionChain( + ChainId, + TenantId, + UserId, + RotationCount + 1, + SecurityVersionAtCreation, + ClaimsSnapshot, + activeSessionId: sessionId, + isRevoked: false, + revokedAt: null + ); + } + + public UAuthSessionChain Revoke(DateTime at) + { + if (IsRevoked) + return this; + + return new UAuthSessionChain( + ChainId, + TenantId, + UserId, + RotationCount, + SecurityVersionAtCreation, + ClaimsSnapshot, + ActiveSessionId, + isRevoked: true, + revokedAt: at + ); + } + + } +} 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..0b0be80e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -0,0 +1,64 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public sealed class UAuthSessionRoot : ISessionRoot + { + public TUserId UserId { get; } + public string? TenantId { get; } + public bool IsRevoked { get; } + public DateTime? RevokedAt { get; } + public long SecurityVersion { get; } + public IReadOnlyList> Chains { get; } + public DateTime LastUpdatedAt { get; } + + private UAuthSessionRoot( + string? tenantId, + TUserId userId, + bool isRevoked, + DateTime? revokedAt, + long securityVersion, + IReadOnlyList> chains, + DateTime lastUpdatedAt) + { + TenantId = tenantId; + UserId = userId; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + SecurityVersion = securityVersion; + Chains = chains; + LastUpdatedAt = lastUpdatedAt; + } + + public static UAuthSessionRoot Create( + string? tenantId, + TUserId userId, + DateTime issuedAt) + { + return new UAuthSessionRoot( + tenantId, + userId, + isRevoked: false, + revokedAt: null, + securityVersion: 0, + chains: Array.Empty>(), + lastUpdatedAt: issuedAt + ); + } + + public UAuthSessionRoot Revoke(DateTime at) + { + if (IsRevoked) + return this; + + return new UAuthSessionRoot( + TenantId, + UserId, + isRevoked: true, + revokedAt: at, + securityVersion: SecurityVersion, + chains: Chains, + lastUpdatedAt: at + ); + } + + } +} 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..84bc03cb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs @@ -0,0 +1,24 @@ +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Framework-agnostic JWT description used by IJwtTokenGenerator. + /// + public sealed class UAuthJwtTokenDescriptor + { + public required ClaimsIdentity Subject { get; init; } + + public required string Issuer { get; init; } + + public required string Audience { get; init; } + + public required DateTime Expires { get; init; } + + /// + /// Signing key material (symmetric or asymmetric). + /// Interpretation is up to the generator implementation. + /// + public required object SigningKey { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Enums/UAuthMode.cs b/src/CodeBeam.UltimateAuth.Core/Enums/UAuthMode.cs new file mode 100644 index 00000000..941a93a5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Enums/UAuthMode.cs @@ -0,0 +1,43 @@ +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/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs index 378585ea..a8f872d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs @@ -32,7 +32,7 @@ public static class UltimateAuthServiceCollectionExtensions /// public static IServiceCollection AddUltimateAuth(this IServiceCollection services, IConfiguration configurationSection) { - services.Configure(configurationSection); + services.Configure(configurationSection); return services.AddUltimateAuthInternal(); } @@ -41,7 +41,7 @@ public static IServiceCollection AddUltimateAuth(this IServiceCollection service /// This is useful when settings are derived dynamically or are not stored /// in appsettings.json. /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action configure) + public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action configure) { services.Configure(configure); return services.AddUltimateAuthInternal(); @@ -54,14 +54,15 @@ public static IServiceCollection AddUltimateAuth(this IServiceCollection service /// public static IServiceCollection AddUltimateAuth(this IServiceCollection services) { - services.Configure(_ => { }); + services.Configure(_ => { }); 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: @@ -72,20 +73,17 @@ public static IServiceCollection AddUltimateAuth(this IServiceCollection service /// private static IServiceCollection AddUltimateAuthInternal(this IServiceCollection services) { - services.AddSingleton, UltimateAuthOptionsValidator>(); - services.AddSingleton, SessionOptionsValidator>(); - services.AddSingleton, TokenOptionsValidator>(); - services.AddSingleton, PkceOptionsValidator>(); - services.AddSingleton, MultiTenantOptionsValidator>(); + services.AddSingleton, UAuthOptionsValidator>(); + services.AddSingleton, UAuthSessionOptionsValidator>(); + services.AddSingleton, UAuthTokenOptionsValidator>(); + services.AddSingleton, UAuthPkceOptionsValidator>(); + services.AddSingleton, UAuthMultiTenantOptionsValidator>(); - // Binding of nested sub-options (Session, Token, etc.) is intentionally not done here. - // These must be bound at the server level to allow configuration per-environment. + // Nested options are bound automatically by the options binder. + // Server layer may override or extend these settings. services.AddSingleton(); - // Default factory throws until a real session store is registered. - services.TryAddSingleton(); - return services; } diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs index 2037e0bb..3f9d93fe 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Core.Extensions { /// - /// Provides extension methods for registering a concrete + /// Provides extension methods for registering a concrete /// implementation into the application's dependency injection container. /// /// UltimateAuth requires exactly one session store implementation that determines @@ -34,16 +34,16 @@ public static IServiceCollection AddUltimateAuthSessionStore(this IServi .GetInterfaces() .FirstOrDefault(i => i.IsGenericType && - i.GetGenericTypeDefinition() == typeof(ISessionStore<>)); + i.GetGenericTypeDefinition() == typeof(ISessionStoreKernel<>)); if (storeInterface is null) { throw new InvalidOperationException( - $"{typeof(TStore).Name} must implement ISessionStore."); + $"{typeof(TStore).Name} must implement ISessionStoreKernel."); } var userIdType = storeInterface.GetGenericArguments()[0]; - var typedInterface = typeof(ISessionStore<>).MakeGenericType(userIdType); + var typedInterface = typeof(ISessionStoreKernel<>).MakeGenericType(userIdType); services.TryAddScoped(typedInterface, typeof(TStore)); @@ -84,7 +84,7 @@ public GenericSessionStoreFactory(IServiceProvider sp, Type userIdType) /// for the specified tenant and user ID type. /// Throws if the requested TUserId does not match the registered store's type. /// - public ISessionStore Create(string? tenantId) + public ISessionStoreKernel Create(string? tenantId) { if (typeof(TUserId) != _userIdType) { @@ -93,10 +93,10 @@ public ISessionStore Create(string? tenantId) $"but requested with TUserId='{typeof(TUserId).Name}'."); } - var typed = typeof(ISessionStore<>).MakeGenericType(_userIdType); + var typed = typeof(ISessionStoreKernel<>).MakeGenericType(_userIdType); var store = _sp.GetRequiredService(typed); - return (ISessionStore)store; + return (ISessionStoreKernel)store; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSession.cs deleted file mode 100644 index 69a9cfdc..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSession.cs +++ /dev/null @@ -1,71 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Internal -{ - internal sealed class UAuthSession : ISession - { - public UAuthSession(AuthSessionId sessionId, TUserId userId, DateTime createdAt, DateTime expiresAt, DateTime lastSeenAt, - long securityVersionAtCreation, DeviceInfo device, SessionMetadata metadata, bool isRevoked = false, - DateTime? revokedAt = null) - { - SessionId = sessionId; - UserId = userId; - CreatedAt = createdAt; - ExpiresAt = expiresAt; - LastSeenAt = lastSeenAt; - SecurityVersionAtCreation = securityVersionAtCreation; - Device = device; - Metadata = metadata; - IsRevoked = isRevoked; - RevokedAt = revokedAt; - } - - public AuthSessionId SessionId { get; } - public TUserId UserId { get; } - - public DateTime CreatedAt { get; } - public DateTime ExpiresAt { get; } - public DateTime LastSeenAt { get; } - - public long SecurityVersionAtCreation { get; } - - public bool IsRevoked { get; } - public DateTime? RevokedAt { get; } - - public DeviceInfo Device { get; } - public SessionMetadata Metadata { get; } - - public SessionState GetState(DateTime now) - { - if (IsRevoked) return SessionState.Revoked; - if (now >= ExpiresAt) return SessionState.Expired; - - return SessionState.Active; - } - - public static UAuthSession CreateNew(TUserId userId, long rootSecurityVersion, DeviceInfo device, SessionMetadata metadata, DateTime now, TimeSpan lifetime) - { - return new UAuthSession( - sessionId: AuthSessionId.New(), - userId: userId, - createdAt: now, - expiresAt: now.Add(lifetime), - lastSeenAt: now, - securityVersionAtCreation: rootSecurityVersion, - device: device, - metadata: metadata - ); - } - - // TODO: WithUpdatedLastSeenAt? Add as optionally used in session validation flow. - public UAuthSession WithRevoked(DateTime at) - { - return new UAuthSession( - SessionId, UserId, CreatedAt, ExpiresAt, LastSeenAt, - SecurityVersionAtCreation, Device, Metadata, - isRevoked: true, - revokedAt: at - ); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionChain.cs deleted file mode 100644 index 4f10170d..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionChain.cs +++ /dev/null @@ -1,76 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Internal -{ - internal sealed class UAuthSessionChain : ISessionChain - { - public UAuthSessionChain(ChainId chainId, TUserId userId, int rotationCount, long securityVersionAtCreation, IReadOnlyDictionary? claimsSnapshot, - IReadOnlyList> sessions, bool isRevoked = false, DateTime? revokedAt = null) - { - ChainId = chainId; - UserId = userId; - RotationCount = rotationCount; - SecurityVersionAtCreation = securityVersionAtCreation; - ClaimsSnapshot = claimsSnapshot; - Sessions = sessions; - IsRevoked = isRevoked; - RevokedAt = revokedAt; - } - - public ChainId ChainId { get; } - public TUserId UserId { get; } - - public int RotationCount { get; } - public long SecurityVersionAtCreation { get; } - - public IReadOnlyDictionary? ClaimsSnapshot { get; } - public IReadOnlyList> Sessions { get; } - - public bool IsRevoked { get; } - public DateTime? RevokedAt { get; } - - public static UAuthSessionChain CreateNew(TUserId userId, long rootSecurityVersion, ISession initialSession, IReadOnlyDictionary? claimsSnapshot) - { - return new UAuthSessionChain( - chainId: ChainId.New(), - userId: userId, - rotationCount: 0, - securityVersionAtCreation: rootSecurityVersion, - claimsSnapshot: claimsSnapshot, - sessions: new[] { initialSession } - ); - } - - public UAuthSessionChain AddRotatedSession(ISession session) - { - var newList = new List>(Sessions.Count + 1); - newList.AddRange(Sessions); - newList.Add(session); - - return new UAuthSessionChain( - ChainId, - UserId, - RotationCount + 1, - SecurityVersionAtCreation, - ClaimsSnapshot, - newList, - IsRevoked, - RevokedAt - ); - } - - public UAuthSessionChain WithRevoked(DateTime at) - { - return new UAuthSessionChain( - ChainId, - UserId, - RotationCount, - SecurityVersionAtCreation, - ClaimsSnapshot, - Sessions, - isRevoked: true, - revokedAt: at - ); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionRoot.cs deleted file mode 100644 index 9696f6cc..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionRoot.cs +++ /dev/null @@ -1,95 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Internal -{ - internal sealed class UAuthSessionRoot : ISessionRoot - { - public UAuthSessionRoot(string? tenantId, TUserId userId, bool isRevoked, DateTime? revokedAt, long securityVersion, IReadOnlyList> chains, DateTime lastUpdatedAt) - { - TenantId = tenantId; - UserId = userId; - IsRevoked = isRevoked; - RevokedAt = revokedAt; - SecurityVersion = securityVersion; - Chains = chains; - LastUpdatedAt = lastUpdatedAt; - } - - public string? TenantId { get; } - public TUserId UserId { get; } - public bool IsRevoked { get; } - public DateTime? RevokedAt { get; } - public long SecurityVersion { get; } - public IReadOnlyList> Chains { get; } - public DateTime LastUpdatedAt { get; } - - public static UAuthSessionRoot CreateNew(string? tenantId, TUserId userId, DateTime now) - { - return new UAuthSessionRoot( - tenantId: tenantId, - userId: userId, - isRevoked: false, - revokedAt: null, - securityVersion: 1, - chains: Array.Empty>(), - lastUpdatedAt: now - ); - } - - public UAuthSessionRoot AddChain(ISessionChain chain, DateTime now) - { - var newList = new List>(Chains.Count + 1); - newList.AddRange(Chains); - newList.Add(chain); - - return new UAuthSessionRoot( - TenantId, - UserId, - IsRevoked, - RevokedAt, - SecurityVersion, - newList, - lastUpdatedAt: now - ); - } - - public UAuthSessionRoot WithSecurityVersionIncrement(DateTime now) - { - return new UAuthSessionRoot( - TenantId, - UserId, - IsRevoked, - RevokedAt, - securityVersion: SecurityVersion + 1, - Chains, - lastUpdatedAt: now - ); - } - - public UAuthSessionRoot WithRevoked(DateTime at) - { - return new UAuthSessionRoot( - TenantId, - UserId, - isRevoked: true, - revokedAt: at, - SecurityVersion, - Chains, - lastUpdatedAt: at - ); - } - - public UAuthSessionRoot WithUnrevoked(DateTime now) - { - return new UAuthSessionRoot( - TenantId, - UserId, - isRevoked: false, - revokedAt: null, - SecurityVersion, - Chains, - lastUpdatedAt: now - ); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionService.cs deleted file mode 100644 index c8a5da2a..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Internal/UAuthSessionService.cs +++ /dev/null @@ -1,170 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Models; -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Core.Internal -{ - internal sealed class UAuthSessionService : ISessionService - { - private readonly ISessionStore _store; - private readonly SessionOptions _options; - - public UAuthSessionService(ISessionStore store, SessionOptions options) - { - _store = store; - _options = options; - } - - public async Task> CreateLoginSessionAsync(string? tenantId, TUserId userId, DeviceInfo device, SessionMetadata? metadata, DateTime now) - { - var root = await _store.GetSessionRootAsync(tenantId, userId) ?? UAuthSessionRoot.CreateNew(tenantId, userId, now); - - var session = UAuthSession.CreateNew( - userId, - root.SecurityVersion, - device, - metadata ?? new SessionMetadata(), - now, - _options.Lifetime - ); - - var chain = UAuthSessionChain.CreateNew( - userId, - root.SecurityVersion, - session, - claimsSnapshot: null - ); - - var concreteRoot = (UAuthSessionRoot)root; - var updatedRoot = concreteRoot.AddChain(chain, now); - - await _store.SaveSessionAsync(tenantId, session); - await _store.SaveChainAsync(tenantId, chain); - await _store.SaveSessionRootAsync(tenantId, updatedRoot); - await _store.SetActiveSessionIdAsync(tenantId, chain.ChainId, session.SessionId); - - return new SessionResult - { - Session = session, - Chain = chain, - Root = updatedRoot - }; - } - - public async Task> RefreshSessionAsync(string? tenantId, AuthSessionId currentSessionId, DateTime now) - { - var oldSession = await _store.GetSessionAsync(tenantId, currentSessionId) ?? throw new InvalidOperationException("Session not found"); - - var chainId = await _store.GetChainIdBySessionAsync(tenantId, currentSessionId) - ?? throw new InvalidOperationException("Chain not found"); - - var chain = await _store.GetChainAsync(tenantId, chainId) - ?? throw new InvalidOperationException("Chain missing"); - - var root = await _store.GetSessionRootAsync(tenantId, oldSession.UserId) - ?? throw new InvalidOperationException("Root missing"); - - if (root.IsRevoked) - throw new UnauthorizedAccessException("Root revoked"); - - if (chain.IsRevoked) - throw new UnauthorizedAccessException("Chain revoked"); - - if (oldSession.SecurityVersionAtCreation != root.SecurityVersion) - throw new UnauthorizedAccessException("SecurityVersion mismatch"); - - if (now >= oldSession.ExpiresAt) - throw new UnauthorizedAccessException("Session expired"); - - var newSession = UAuthSession.CreateNew( - oldSession.UserId, - root.SecurityVersion, - oldSession.Device, - oldSession.Metadata, - now, - _options.Lifetime - ); - - var concreteChain = (UAuthSessionChain)chain; - var rotatedChain = concreteChain.AddRotatedSession(newSession); - - await _store.SaveSessionAsync(tenantId, newSession); - await _store.UpdateChainAsync(tenantId, rotatedChain); - await _store.SetActiveSessionIdAsync(tenantId, chain.ChainId, newSession.SessionId); - - return new SessionResult - { - Session = newSession, - Chain = rotatedChain, - Root = root - }; - } - - public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at) - { - await _store.RevokeSessionAsync(tenantId, sessionId, at); - } - - public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at) - { - await _store.RevokeChainAsync(tenantId, chainId, at); - } - - public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at) - { - await _store.RevokeSessionRootAsync(tenantId, userId, at); - } - - public async Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime now) - { - var session = await _store.GetSessionAsync(tenantId, sessionId); - - if (session == null) - { - return new SessionValidationResult - { - State = SessionState.Expired - }; - } - - var chainId = await _store.GetChainIdBySessionAsync(tenantId, sessionId); - var chain = chainId == null ? null : await _store.GetChainAsync(tenantId, chainId.Value); - var root = await _store.GetSessionRootAsync(tenantId, session.UserId); - - var state = ComputeState(session, chain, root, now); - - return new SessionValidationResult - { - Session = session, - Chain = chain, - Root = root, - State = state - }; - } - - private SessionState ComputeState(ISession session, ISessionChain? chain, ISessionRoot? root, DateTime now) - { - if (root == null || chain == null) - return SessionState.Expired; - - if (root.IsRevoked) return SessionState.RootRevoked; - if (chain.IsRevoked) return SessionState.ChainRevoked; - - if (session.IsRevoked) return SessionState.Revoked; - if (now >= session.ExpiresAt) return SessionState.Expired; - - if (session.SecurityVersionAtCreation != root.SecurityVersion) - return SessionState.SecurityVersionMismatch; - - return SessionState.Active; - } - - public Task>> GetChainsAsync(string? tenantId, TUserId userId) - => _store.GetChainsByUserAsync(tenantId, userId); - - public Task>> GetSessionsAsync(string? tenantId, ChainId chainId) - => _store.GetSessionsByChainAsync(tenantId, chainId); - - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs index 1dc03b9c..af82c698 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs @@ -3,15 +3,15 @@ /// /// Executes multiple tenant resolvers in order; the first resolver returning a non-null tenant id wins. /// - public sealed class CompositeTenantResolver : ITenantResolver + public sealed class CompositeTenantResolver : ITenantIdResolver { - private readonly IReadOnlyList _resolvers; + 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) + public CompositeTenantResolver(IEnumerable resolvers) { _resolvers = resolvers.ToList(); } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs index 84b70d4b..28b85062 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs @@ -3,7 +3,7 @@ /// /// Returns a constant tenant id for all resolution requests; useful for single-tenant or statically configured systems. /// - public sealed class FixedTenantResolver : ITenantResolver + public sealed class FixedTenantResolver : ITenantIdResolver { private readonly string _tenantId; diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs index bf33d311..e969f0d7 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs @@ -5,7 +5,7 @@ /// Example: X-Tenant: foo → returns "foo". /// Useful when multi-tenancy is controlled by API gateways or reverse proxies. /// - public sealed class HeaderTenantResolver : ITenantResolver + public sealed class HeaderTenantResolver : ITenantIdResolver { private readonly string _headerName; diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs index 00d5bd92..f411b8dd 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs @@ -1,34 +1,30 @@ namespace CodeBeam.UltimateAuth.Core.MultiTenancy { - 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 { /// - /// Resolves the tenant id based on the request host name. - /// Example: foo.example.com → returns "foo". - /// Useful in subdomain-based multi-tenant architectures. + /// 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 sealed class HostTenantResolver : ITenantResolver + public Task ResolveTenantIdAsync(TenantResolutionContext context) { - /// - /// 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; + var host = context.Host; - if (string.IsNullOrWhiteSpace(host)) - return Task.FromResult(null); + if (string.IsNullOrWhiteSpace(host)) + return Task.FromResult(null); - var parts = host.Split('.', StringSplitOptions.RemoveEmptyEntries); + var parts = host.Split('.', StringSplitOptions.RemoveEmptyEntries); - // Expecting at least: {tenant}.{domain}.{tld} - if (parts.Length < 3) - return Task.FromResult(null); + // Expecting at least: {tenant}.{domain}.{tld} + if (parts.Length < 3) + return Task.FromResult(null); - return Task.FromResult(parts[0]); - } + return Task.FromResult(parts[0]); } - } } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantResolver.cs rename to src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs index bd448a16..cc7867c4 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs @@ -5,7 +5,7 @@ /// Implementations may extract the tenant from headers, hostnames, /// authentication tokens, or any other application-defined source. /// - public interface ITenantResolver + public interface ITenantIdResolver { /// /// Attempts to resolve the tenant id given the contextual request data. diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs index 11df9746..38c3f772 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs @@ -4,7 +4,7 @@ /// Resolves the tenant id from the request path. /// Example pattern: /t/{tenantId}/... → returns the extracted tenant id. /// - public sealed class PathTenantResolver : ITenantResolver + public sealed class PathTenantResolver : ITenantIdResolver { private readonly string _prefix; diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs index d5fd3a50..6ae1c483 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs @@ -31,7 +31,36 @@ public sealed class TenantResolutionContext /// /// 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/TenantValidation.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantValidation.cs new file mode 100644 index 00000000..c33d8b77 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantValidation.cs @@ -0,0 +1,28 @@ +using System.Text.RegularExpressions; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.MultiTenancy +{ + internal static class TenantValidation + { + public static UAuthTenantContext FromResolvedTenant( + string rawTenantId, + UAuthMultiTenantOptions options) + { + if (string.IsNullOrWhiteSpace(rawTenantId)) + return UAuthTenantContext.NotResolved(); + + var tenantId = options.NormalizeToLowercase + ? rawTenantId.ToLowerInvariant() + : rawTenantId; + + if (!Regex.IsMatch(tenantId, options.TenantIdRegex)) + return UAuthTenantContext.NotResolved(); + + if (options.ReservedTenantIds.Contains(tenantId)) + return UAuthTenantContext.NotResolved(); + + return UAuthTenantContext.Resolved(tenantId); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs new file mode 100644 index 00000000..9874068a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs @@ -0,0 +1,23 @@ +namespace CodeBeam.UltimateAuth.Core.MultiTenancy +{ + /// + /// Represents the resolved tenant result for the current request. + /// + public sealed class UAuthTenantContext + { + public string? TenantId { get; } + public bool IsResolved { get; } + + private UAuthTenantContext(string? tenantId, bool resolved) + { + TenantId = tenantId; + IsResolved = resolved; + } + + public static UAuthTenantContext NotResolved() + => new(null, false); + + public static UAuthTenantContext Resolved(string tenantId) + => new(tenantId, true); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/LoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Core/Options/LoginOptions.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 320299fd..46677d74 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/LoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -4,7 +4,7 @@ /// Configuration settings related to interactive user login behavior, /// including lockout policies and failed-attempt thresholds. /// - public sealed class LoginOptions + public sealed class UAuthLoginOptions { /// /// Maximum number of consecutive failed login attempts allowed diff --git a/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs similarity index 79% rename from src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptions.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs index 5fae413d..6774eb71 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs @@ -5,7 +5,7 @@ /// Controls whether tenants are required, how they are resolved, /// and how tenant identifiers are normalized. /// - public sealed class MultiTenantOptions + public sealed class UAuthMultiTenantOptions { /// /// Enables multi-tenant mode. @@ -17,7 +17,7 @@ public sealed class MultiTenantOptions /// If tenant cannot be resolved, this value is used. /// If null and RequireTenant = true, request fails. /// - public string? DefaultTenantId { get; set; } = "default"; + public string? DefaultTenantId { get; set; } /// /// If true, a resolved tenant id must always exist. @@ -55,5 +55,16 @@ public sealed class MultiTenantOptions /// Default: alphanumeric + hyphens allowed. /// public string TenantIdRegex { get; set; } = "^[a-zA-Z0-9\\-]+$"; + + /// + /// Enables tenant resolution from the URL path and + /// exposes auth endpoints under /{tenant}/{routePrefix}/... + /// + public bool EnableRoute { get; set; } = true; + public bool EnableHeader { get; set; } = false; + public bool EnableDomain { get; set; } = false; + + // Header config + public string HeaderName { get; set; } = "X-Tenant"; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs similarity index 89% rename from src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs index b6b25e92..74828a1d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/MultiTenantOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs @@ -4,15 +4,15 @@ namespace CodeBeam.UltimateAuth.Core.Options { /// - /// Validates at application startup. + /// Validates at application startup. /// Ensures that tenant configuration values (regex patterns, defaults, /// reserved identifiers, and requirement rules) are logically consistent /// and safe to use before multi-tenant authentication begins. /// - internal sealed class MultiTenantOptionsValidator : IValidateOptions + internal sealed class UAuthMultiTenantOptionsValidator : IValidateOptions { /// - /// Performs validation on the provided instance. + /// Performs validation on the provided instance. /// This method enforces: /// - valid tenant id regex format, /// - reserved tenant ids matching the regex, @@ -25,7 +25,7 @@ internal sealed class MultiTenantOptionsValidator : IValidateOptions indicating success or the /// specific configuration error encountered. /// - public ValidateOptionsResult Validate(string? name, MultiTenantOptions options) + public ValidateOptionsResult Validate(string? name, UAuthMultiTenantOptions options) { // Multi-tenancy disabled → no validation needed if (!options.Enabled) diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptions.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs index fd015b4d..2915ce2e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs @@ -11,31 +11,31 @@ namespace CodeBeam.UltimateAuth.Core.Options /// All sub-options are resolved from configuration (appsettings.json) /// or through inline setup in AddUltimateAuth(). /// - public sealed class UltimateAuthOptions + public sealed class UAuthOptions { /// /// Configuration settings for interactive login flows, /// including lockout thresholds and failed-attempt policies. /// - public LoginOptions Login { get; set; } = new(); + public UAuthLoginOptions Login { get; set; } = new(); /// /// Settings that control session creation, refresh behavior, /// sliding expiration, idle timeouts, device limits, and chain rules. /// - public SessionOptions Session { get; set; } = new(); + public UAuthSessionOptions Session { get; set; } = new(); /// /// Token issuance configuration, including JWT and opaque token /// generation, lifetimes, signing keys, and audience/issuer values. /// - public TokenOptions Token { get; set; } = new(); + public UAuthTokenOptions Token { get; set; } = new(); /// /// PKCE (Proof Key for Code Exchange) configuration used for /// browser-based login flows and WASM authentication. /// - public PkceOptions Pkce { get; set; } = new(); + public UAuthPkceOptions Pkce { get; set; } = new(); /// /// Event hooks raised during authentication lifecycle events @@ -47,7 +47,7 @@ public sealed class UltimateAuthOptions /// Multi-tenancy configuration controlling how tenants are resolved, /// validated, and optionally enforced. /// - public MultiTenantOptions MultiTenantOptions { get; set; } = new(); + public UAuthMultiTenantOptions MultiTenantOptions { get; set; } = new(); /// /// Provides converters used to normalize and serialize TUserId diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs similarity index 88% rename from src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs index e3134564..405681e0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UltimateAuthOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs @@ -2,9 +2,9 @@ namespace CodeBeam.UltimateAuth.Core.Options { - internal sealed class UltimateAuthOptionsValidator : IValidateOptions + internal sealed class UAuthOptionsValidator : IValidateOptions { - public ValidateOptionsResult Validate(string? name, UltimateAuthOptions options) + public ValidateOptionsResult Validate(string? name, UAuthOptions options) { var errors = new List(); diff --git a/src/CodeBeam.UltimateAuth.Core/Options/PkceOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Options/PkceOptions.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs index ce1a7bd5..18af314d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/PkceOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs @@ -5,7 +5,7 @@ /// authorization flows. Controls how long authorization codes remain /// valid before they must be exchanged for tokens. /// - public sealed class PkceOptions + public sealed class UAuthPkceOptions { /// /// Lifetime of a PKCE authorization code in seconds. diff --git a/src/CodeBeam.UltimateAuth.Core/Options/PkceOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs similarity index 73% rename from src/CodeBeam.UltimateAuth.Core/Options/PkceOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs index 6c95a63d..744d81a5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/PkceOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs @@ -2,9 +2,9 @@ namespace CodeBeam.UltimateAuth.Core.Options { - internal sealed class PkceOptionsValidator : IValidateOptions + internal sealed class UAuthPkceOptionsValidator : IValidateOptions { - public ValidateOptionsResult Validate(string? name, PkceOptions options) + public ValidateOptionsResult Validate(string? name, UAuthPkceOptions options) { var errors = new List(); diff --git a/src/CodeBeam.UltimateAuth.Core/Options/SessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Core/Options/SessionOptions.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 696a197a..60776dff 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/SessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -8,7 +8,7 @@ /// These values influence how sessions are created, refreshed, /// expired, revoked, and grouped into device chains. /// - public sealed class SessionOptions + public sealed class UAuthSessionOptions { /// /// The standard lifetime of a session before it expires. @@ -18,10 +18,10 @@ public sealed class SessionOptions /// /// Maximum absolute lifetime a session may have, even when - /// sliding expiration is enabled. If set to zero, no hard cap + /// sliding expiration is enabled. If null, no hard cap /// is applied. /// - public TimeSpan MaxLifetime { get; set; } = TimeSpan.Zero; + public TimeSpan? MaxLifetime { get; set; } /// /// When enabled, each refresh extends the session's expiration, @@ -33,7 +33,7 @@ public sealed class SessionOptions /// Maximum allowed idle time before the session becomes invalid. /// If null or zero, idle expiration is disabled entirely. /// - public TimeSpan? IdleTimeout { get; set; } = TimeSpan.Zero; + public TimeSpan? IdleTimeout { get; set; } /// /// Maximum number of device session chains a single user may have. diff --git a/src/CodeBeam.UltimateAuth.Core/Options/SessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs similarity index 95% rename from src/CodeBeam.UltimateAuth.Core/Options/SessionOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs index c38d6424..1d81b1d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/SessionOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs @@ -2,9 +2,9 @@ namespace CodeBeam.UltimateAuth.Core.Options { - internal sealed class SessionOptionsValidator : IValidateOptions + internal sealed class UAuthSessionOptionsValidator : IValidateOptions { - public ValidateOptionsResult Validate(string? name, SessionOptions options) + public ValidateOptionsResult Validate(string? name, UAuthSessionOptions options) { var errors = new List(); diff --git a/src/CodeBeam.UltimateAuth.Core/Options/TokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs similarity index 88% rename from src/CodeBeam.UltimateAuth.Core/Options/TokenOptions.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs index 8771aef3..764eaf2e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/TokenOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -5,7 +5,7 @@ /// within UltimateAuth. Includes JWT and opaque token generation, /// lifetimes, and cryptographic settings. /// - public sealed class TokenOptions + public sealed class UAuthTokenOptions { /// /// Determines whether JWT-format access tokens should be issued. @@ -53,5 +53,11 @@ public sealed class TokenOptions /// 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; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/TokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs similarity index 91% rename from src/CodeBeam.UltimateAuth.Core/Options/TokenOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs index 563c4c08..084c4da0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/TokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs @@ -2,9 +2,9 @@ namespace CodeBeam.UltimateAuth.Core.Options { - internal sealed class TokenOptionsValidator : IValidateOptions + internal sealed class UAuthTokenOptionsValidator : IValidateOptions { - public ValidateOptionsResult Validate(string? name, TokenOptions options) + public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) { var errors = new List(); diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index 8004a0dd..d4e9395d 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -7,4 +7,16 @@ true + + + + + + + + + + + + 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..6b464500 --- /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); + } +} 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..3185a333 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface ILogoutEndpointHandler + { + Task LogoutAsync(HttpContext ctx); + } +} 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..d9324a46 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IPkceEndpointHandler + { + Task CreateAsync(HttpContext ctx); + Task VerifyAsync(HttpContext ctx); + Task ConsumeAsync(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..4de1bae8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs @@ -0,0 +1,9 @@ +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/ISessionManagementHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs new file mode 100644 index 00000000..1d5c9288 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface ISessionManagementHandler + { + Task GetCurrentSessionAsync(HttpContext ctx); + Task GetAllSessionsAsync(HttpContext ctx); + Task RevokeSessionAsync(string sessionId, HttpContext ctx); + Task RevokeAllAsync(HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionRefreshEndpointHandler.cs new file mode 100644 index 00000000..15261fda --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionRefreshEndpointHandler.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface ISessionRefreshEndpointHandler + { + Task RefreshSessionAsync(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..e69a1e55 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs @@ -0,0 +1,12 @@ +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/IUserInfoEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs new file mode 100644 index 00000000..54c0eae6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs @@ -0,0 +1,11 @@ +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/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs new file mode 100644 index 00000000..2b94841b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public class DefaultLoginEndpointHandler : ILoginEndpointHandler + { + public Task LoginAsync(HttpContext ctx) + { + return Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs new file mode 100644 index 00000000..873a6fb1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public class DefaultPkceEndpointHandler : IPkceEndpointHandler + { + public Task CreateAsync(HttpContext ctx) + => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); + + public Task VerifyAsync(HttpContext ctx) + => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); + + public Task ConsumeAsync(HttpContext ctx) + => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/EndpointEnablement.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/EndpointEnablement.cs new file mode 100644 index 00000000..7863eaec --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/EndpointEnablement.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + internal static class EndpointEnablement + { + public static bool Resolve(bool? overrideValue, bool modeDefault) + => overrideValue ?? modeDefault; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaults.cs new file mode 100644 index 00000000..3eea6f60 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaults.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Server +{ + /// + /// Represents which endpoint groups are enabled by default + /// for a given authentication mode. + /// + public sealed class UAuthEndpointDefaults + { + public bool Login { get; init; } + public bool Pkce { get; init; } + public bool Token { get; init; } + public bool Session { get; init; } + public bool UserInfo { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs new file mode 100644 index 00000000..0a7b6ffe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs @@ -0,0 +1,56 @@ +using CodeBeam.UltimateAuth.Core; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + /// + /// Provides default endpoint enablement rules based on UAuthMode. + /// These defaults represent the secure and meaningful surface + /// for each authentication strategy. + /// + internal static class UAuthEndpointDefaultsMap + { + public static UAuthEndpointDefaults ForMode(UAuthMode mode) + { + return mode switch + { + UAuthMode.PureOpaque => new UAuthEndpointDefaults + { + Login = true, + Pkce = false, + Token = false, + Session = true, + UserInfo = true + }, + + UAuthMode.Hybrid => new UAuthEndpointDefaults + { + Login = true, + Pkce = true, + Token = true, + Session = true, + UserInfo = true + }, + + UAuthMode.SemiHybrid => new UAuthEndpointDefaults + { + Login = true, + Pkce = true, + Token = true, + Session = false, + UserInfo = true + }, + + UAuthMode.PureJwt => new UAuthEndpointDefaults + { + Login = true, + Pkce = false, + Token = true, + Session = false, + UserInfo = true + }, + + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs new file mode 100644 index 00000000..5169cd52 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -0,0 +1,109 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IAuthEndpointRegistrar + { + void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options); + } + + public class UAuthEndpointRegistrar : IAuthEndpointRegistrar + { + public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) + { + var defaults = UAuthEndpointDefaultsMap.ForMode(options.Mode); + + // Base: /auth + string basePrefix = options.RoutePrefix.TrimStart('/'); + + bool useRouteTenant = + options.MultiTenant.Enabled && + options.MultiTenant.EnableRoute; + + RouteGroupBuilder group = useRouteTenant + ? rootGroup.MapGroup("/{tenant}/" + basePrefix) + : rootGroup.MapGroup("/" + basePrefix); + + if (EndpointEnablement.Resolve(options.EnablePkceEndpoints, defaults.Pkce)) + { + var pkce = group.MapGroup("/pkce"); + + pkce.MapPost("/create", async (IPkceEndpointHandler h, HttpContext ctx) + => await h.CreateAsync(ctx)); + + pkce.MapPost("/verify", async (IPkceEndpointHandler h, HttpContext ctx) + => await h.VerifyAsync(ctx)); + + pkce.MapPost("/consume", async (IPkceEndpointHandler h, HttpContext ctx) + => await h.ConsumeAsync(ctx)); + } + + if (EndpointEnablement.Resolve(options.EnableLoginEndpoints, defaults.Login)) + { + group.MapPost("/login", async (ILoginEndpointHandler h, HttpContext ctx) + => await h.LoginAsync(ctx)); + + group.MapPost("/logout", async (ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutAsync(ctx)); + + group.MapPost("/refresh-session", async (ISessionRefreshEndpointHandler h, HttpContext ctx) + => await h.RefreshSessionAsync(ctx)); + + group.MapPost("/reauth", async (IReauthEndpointHandler h, HttpContext ctx) + => await h.ReauthAsync(ctx)); + } + + if (EndpointEnablement.Resolve(options.EnableTokenEndpoints, defaults.Token)) + { + var token = group.MapGroup(""); + + token.MapPost("/token", async (ITokenEndpointHandler h, HttpContext ctx) + => await h.GetTokenAsync(ctx)); + + token.MapPost("/refresh-token", async (ITokenEndpointHandler h, HttpContext ctx) + => await h.RefreshTokenAsync(ctx)); + + token.MapPost("/introspect", async (ITokenEndpointHandler h, HttpContext ctx) + => await h.IntrospectAsync(ctx)); + + token.MapPost("/revoke", async (ITokenEndpointHandler h, HttpContext ctx) + => await h.RevokeAsync(ctx)); + } + + if (EndpointEnablement.Resolve(options.EnableSessionEndpoints, defaults.Session)) + { + var session = group.MapGroup("/session"); + + session.MapGet("/current", async (ISessionManagementHandler h, HttpContext ctx) + => await h.GetCurrentSessionAsync(ctx)); + + session.MapGet("/list", async (ISessionManagementHandler h, HttpContext ctx) + => await h.GetAllSessionsAsync(ctx)); + + session.MapPost("/revoke/{sessionId}", async (ISessionManagementHandler h, string sessionId, HttpContext ctx) + => await h.RevokeSessionAsync(sessionId, ctx)); + + session.MapPost("/revoke-all", async (ISessionManagementHandler h, HttpContext ctx) + => await h.RevokeAllAsync(ctx)); + } + + if (EndpointEnablement.Resolve(options.EnableUserInfoEndpoints, defaults.UserInfo)) + { + var user = group.MapGroup(""); + + user.MapGet("/userinfo", async (IUserInfoEndpointHandler h, HttpContext ctx) + => await h.GetUserInfoAsync(ctx)); + + user.MapGet("/permissions", async (IUserInfoEndpointHandler h, HttpContext ctx) + => await h.GetPermissionsAsync(ctx)); + + user.MapPost("/permissions/check", async (IUserInfoEndpointHandler h, HttpContext ctx) + => await h.CheckPermissionAsync(ctx)); + } + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs new file mode 100644 index 00000000..474f7831 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Server.Middlewares; +using CodeBeam.UltimateAuth.Server.Sessions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions +{ + public static class HttpContextSessionExtensions + { + public static SessionContext GetSessionContext( + this HttpContext context) + { + if (context.Items.TryGetValue( + SessionResolutionMiddleware.SessionContextKey, + out var value) + && value is SessionContext session) + { + return session; + } + + return SessionContext.Anonymous(); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs new file mode 100644 index 00000000..6a74f8a3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Middlewares; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions +{ + public static class HttpContextTenantExtensions + { + public static string? GetTenantId(this HttpContext ctx) + { + return ctx.GetTenantContext().TenantId; + } + + public static UAuthTenantContext GetTenantContext(this HttpContext ctx) + { + if (ctx.Items.TryGetValue( + TenantMiddleware.TenantContextKey, + out var value) + && value is UAuthTenantContext tenant) + { + return tenant; + } + + return UAuthTenantContext.NotResolved(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/TenantResolutionContextExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/TenantResolutionContextExtensions.cs new file mode 100644 index 00000000..11a98d17 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/TenantResolutionContextExtensions.cs @@ -0,0 +1,25 @@ +//using Microsoft.AspNetCore.Http; +//using CodeBeam.UltimateAuth.Core.MultiTenancy; + +//namespace CodeBeam.UltimateAuth.Server.MultiTenancy +//{ +// public static class TenantResolutionContextExtensions +// { +// public static TenantResolutionContext FromHttpContext(this HttpContext ctx) +// { +// var headers = ctx.Request.Headers +// .ToDictionary( +// h => h.Key, +// h => h.Value.ToString(), +// StringComparer.OrdinalIgnoreCase); + +// return new TenantResolutionContext +// { +// Headers = headers, +// Host = ctx.Request.Host.Host, +// Path = ctx.Request.Path.Value, +// RawContext = ctx +// }; +// } +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs new file mode 100644 index 00000000..1d0c4feb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Server.Middlewares; +using Microsoft.AspNetCore.Builder; + +namespace CodeBeam.UltimateAuth.Server.Extensions +{ + public static class UltimateAuthApplicationBuilderExtensions + { + public static IApplicationBuilder UseUltimateAuthServer( + this IApplicationBuilder app) + { + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + + return app; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs new file mode 100644 index 00000000..8520014b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -0,0 +1,105 @@ +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Issuers; +using CodeBeam.UltimateAuth.Server.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Extensions +{ + public static class UAuthServerServiceCollectionExtensions + { + public static IServiceCollection AddUltimateAuthServer( + this IServiceCollection services) + { + services.AddUltimateAuth(); // Core + return services.AddUltimateAuthServerInternal(); + } + + public static IServiceCollection AddUltimateAuthServer( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddUltimateAuth(configuration); // Core + services.Configure( + configuration.GetSection("UltimateAuth:Server")); + + services.Configure( + configuration.GetSection("UltimateAuth:SessionResolution")); + + return services.AddUltimateAuthServerInternal(); + } + + public static IServiceCollection AddUltimateAuthServer( + this IServiceCollection services, + Action configure) + { + services.AddUltimateAuth(); // Core + services.Configure(configure); + + return services.AddUltimateAuthServerInternal(); + } + + private static IServiceCollection AddUltimateAuthServerInternal( + this IServiceCollection services) + { + // ----------------------------- + // OPTIONS VALIDATION + // ----------------------------- + services.TryAddEnumerable( + ServiceDescriptor.Singleton< + IValidateOptions, + UAuthServerOptionsValidator>()); + + // ----------------------------- + // 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 FixedTenantResolver(opts.DefaultTenantId ?? "default"), + 1 => resolvers[0], + _ => new CompositeTenantResolver(resolvers) + }; + }); + + services.TryAddScoped(); + + // ----------------------------- + // SESSION / TOKEN ISSUERS + // ----------------------------- + services.TryAddScoped( + typeof(UAuthSessionIssuer<>), + typeof(UAuthSessionIssuer<>)); + + services.TryAddScoped(); + + // ----------------------------- + // ENDPOINTS + // ----------------------------- + services.TryAddSingleton(); + + return services; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs new file mode 100644 index 00000000..3a224faa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs @@ -0,0 +1,70 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Domain.Session; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Issuers +{ + /// + /// UltimateAuth session issuer responsible for creating + /// opaque authentication sessions. + /// + public sealed class UAuthSessionIssuer : ISessionIssuer + { + private readonly IOpaqueTokenGenerator _opaqueGenerator; + private readonly UAuthServerOptions _options; + + public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, IOptions options) + { + _opaqueGenerator = opaqueGenerator; + _options = options.Value; + } + + public Task> IssueAsync( + SessionIssueContext context, + UAuthSessionChain chain, + CancellationToken cancellationToken = default) + { + if (_options.Mode == UAuthMode.PureJwt) + { + throw new InvalidOperationException( + "Session issuer cannot be used in PureJwt mode."); + } + + var opaqueSessionId = _opaqueGenerator.Generate(); + var expiresAt = context.Now.Add(_options.Session.Lifetime); + + if (_options.Session.MaxLifetime is not null) + { + var absoluteExpiry = + context.Now.Add(_options.Session.MaxLifetime.Value); + + if (absoluteExpiry < expiresAt) + { + expiresAt = absoluteExpiry; + } + } + + var session = UAuthSession.Create( + sessionId: new AuthSessionId(opaqueSessionId), + tenantId: context.TenantId, + userId: context.UserId, + now: context.Now, + expiresAt: expiresAt, + securityVersion: context.SecurityVersion, + device: context.Device, + metadata: SessionMetadata.Empty + ); + + return Task.FromResult(new IssuedSession + { + Session = session, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs new file mode 100644 index 00000000..46d6e3fb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs @@ -0,0 +1,126 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.Issuers +{ + /// + /// 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 UAuthServerOptions _options; + + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IOptions options) + { + _opaqueGenerator = opaqueGenerator; + _jwtGenerator = jwtGenerator; + _tokenHasher = tokenHasher; + _options = options.Value; + } + + public Task IssueAccessTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var expires = now.Add(_options.Tokens.AccessTokenLifetime); + + return _options.Mode switch + { + UAuthMode.PureOpaque => Task.FromResult(IssueOpaqueAccessToken( + expires, + context.SessionId)), + + UAuthMode.Hybrid or + UAuthMode.SemiHybrid or + UAuthMode.PureJwt => Task.FromResult(IssueJwtAccessToken( + context, + expires)), + + _ => throw new InvalidOperationException( + $"Unsupported auth mode: {_options.Mode}") + }; + } + + public Task IssueRefreshTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default) + { + if (_options.Mode == UAuthMode.PureOpaque) + return Task.FromResult(null); + + var now = DateTimeOffset.UtcNow; + var expires = now.Add(_options.Tokens.RefreshTokenLifetime); + + string token = _opaqueGenerator.Generate(); + string hash = _tokenHasher.Hash(token); + + return Task.FromResult(new IssuedRefreshToken + { + Token = token, + TokenHash = hash, + ExpiresAt = expires + }); + } + + private IssuedAccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) + { + string token = _opaqueGenerator.Generate(); + + return new IssuedAccessToken + { + Token = token, + TokenType = "opaque", + ExpiresAt = expires, + SessionId = sessionId + }; + } + + private IssuedAccessToken IssueJwtAccessToken(TokenIssueContext context, DateTimeOffset expires) + { + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, context.UserId), + new Claim("tenant", context.TenantId) + }; + + claims.AddRange(context.Claims); + + if (!string.IsNullOrWhiteSpace(context.SessionId)) + { + claims.Add(new Claim("sid", context.SessionId)); + } + + if (_options.Tokens.AddJwtIdClaim) + { + string jti = _opaqueGenerator.Generate(16); // shorter is fine + claims.Add(new Claim("jti", jti)); + } + + var descriptor = new UAuthJwtTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Issuer = _options.Tokens.Issuer, + Audience = _options.Tokens.Audience, + Expires = expires.UtcDateTime, + SigningKey = _options.Tokens.SigningKey + }; + + string jwt = _jwtGenerator.CreateToken(descriptor); + + return new IssuedAccessToken + { + Token = jwt, + TokenType = "jwt", + ExpiresAt = expires, + SessionId = context.SessionId + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs new file mode 100644 index 00000000..3ec6a3b4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Sessions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Middlewares +{ + public sealed class SessionResolutionMiddleware + { + private readonly RequestDelegate _next; + private readonly ISessionIdResolver _sessionIdResolver; + + public const string SessionContextKey = "__UAuthSession"; + + public SessionResolutionMiddleware( + RequestDelegate next, + ISessionIdResolver sessionIdResolver) + { + _next = next; + _sessionIdResolver = sessionIdResolver; + } + + public async Task InvokeAsync(HttpContext context) + { + var tenant = context.GetTenantContext(); + var sessionId = _sessionIdResolver.Resolve(context); + + var sessionContext = sessionId is null + ? SessionContext.Anonymous() + : SessionContext.FromSessionId( + sessionId.Value, + tenant.TenantId); + + context.Items[SessionContextKey] = sessionContext; + + 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..6650b1f5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -0,0 +1,53 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.MultiTenancy; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Middlewares +{ + public sealed class TenantMiddleware + { + private readonly RequestDelegate _next; + private readonly ITenantResolver _resolver; + private readonly UAuthMultiTenantOptions _options; + + public const string TenantContextKey = "__UAuthTenant"; + + public TenantMiddleware( + RequestDelegate next, + ITenantResolver resolver, + UAuthMultiTenantOptions options) + { + _next = next; + _resolver = resolver; + _options = options; + } + + public async Task InvokeAsync(HttpContext context) + { + UAuthTenantContext tenantContext; + + if (!_options.Enabled) + { + // Single-tenant mode → tenant concept disabled + tenantContext = UAuthTenantContext.NotResolved(); + } + else + { + tenantContext = await _resolver.ResolveAsync(context); + + if (_options.RequireTenant && !tenantContext.IsResolved) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync( + "Tenant is required but could not be resolved."); + return; + } + } + + context.Items[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..aa87a464 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Server.Users; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Middlewares +{ + public sealed class UserMiddleware + { + private readonly RequestDelegate _next; + private readonly IUserAccessor _userAccessor; + + public const string UserContextKey = "__UAuthUser"; + + public UserMiddleware( + RequestDelegate next, + IUserAccessor userAccessor) + { + _next = next; + _userAccessor = userAccessor; + } + + public async Task InvokeAsync(HttpContext context) + { + await _userAccessor.ResolveAsync(context); + await _next(context); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs new file mode 100644 index 00000000..88b3c741 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy +{ + public sealed class DomainTenantAdapter : ITenantResolver + { + private readonly ITenantIdResolver _coreResolver; + private readonly UAuthMultiTenantOptions _options; + + public DomainTenantAdapter( + HostTenantResolver coreResolver, + UAuthMultiTenantOptions options) + { + _coreResolver = coreResolver; + _options = options; + } + + public async Task ResolveAsync(HttpContext ctx) + { + if (!_options.Enabled) + return UAuthTenantContext.NotResolved(); + + var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); + var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); + + if (tenantId is null) + return UAuthTenantContext.NotResolved(); + + return UAuthTenantContextFactory.Create(tenantId, _options); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs new file mode 100644 index 00000000..8250cdb8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy +{ + public sealed class HeaderTenantAdapter : ITenantResolver + { + private readonly ITenantIdResolver _coreResolver; + private readonly UAuthMultiTenantOptions _options; + + public HeaderTenantAdapter( + HeaderTenantResolver coreResolver, + UAuthMultiTenantOptions options) + { + _coreResolver = coreResolver; + _options = options; + } + + public async Task ResolveAsync(HttpContext ctx) + { + if (!_options.Enabled) + return UAuthTenantContext.NotResolved(); + + var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); + + var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); + if (tenantId is null) + return UAuthTenantContext.NotResolved(); + + return UAuthTenantContextFactory.Create(tenantId, _options); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs new file mode 100644 index 00000000..60b1223a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy +{ + public interface ITenantResolver + { + Task ResolveAsync(HttpContext ctx); + } +} + diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs new file mode 100644 index 00000000..e69719ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.MultiTenancy; +using Microsoft.AspNetCore.Http; + +public sealed class RouteTenantAdapter : ITenantResolver +{ + private readonly ITenantIdResolver _coreResolver; + private readonly UAuthMultiTenantOptions _options; + + public RouteTenantAdapter( + PathTenantResolver coreResolver, + UAuthMultiTenantOptions options) + { + _coreResolver = coreResolver; + _options = options; + } + + public async Task ResolveAsync(HttpContext ctx) + { + if (!_options.Enabled) + return UAuthTenantContext.NotResolved(); + + var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); + var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); + + if (tenantId is null) + return UAuthTenantContext.NotResolved(); + + return UAuthTenantContextFactory.Create(tenantId, _options); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs new file mode 100644 index 00000000..ae7457a9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs @@ -0,0 +1,31 @@ +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..c9531f0e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using System.Text.RegularExpressions; + +public static class UAuthTenantContextFactory +{ + public static UAuthTenantContext Create( + string tenantId, + UAuthMultiTenantOptions options) + { + if (options.NormalizeToLowercase) + tenantId = tenantId.ToLowerInvariant(); + + if (!Regex.IsMatch(tenantId, options.TenantIdRegex)) + return UAuthTenantContext.NotResolved(); + + if (options.ReservedTenantIds.Contains(tenantId)) + return UAuthTenantContext.NotResolved(); + + return UAuthTenantContext.Resolved(tenantId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs new file mode 100644 index 00000000..2988d915 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -0,0 +1,75 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy +{ + /// + /// Server-level tenant resolver. + /// Responsible for executing core tenant id resolvers and + /// applying UltimateAuth tenant policies. + /// + public sealed class UAuthTenantResolver : ITenantResolver + { + private readonly ITenantIdResolver _idResolver; + private readonly UAuthMultiTenantOptions _options; + + public UAuthTenantResolver( + ITenantIdResolver idResolver, + UAuthMultiTenantOptions options) + { + _idResolver = idResolver; + _options = options; + } + + public async Task ResolveAsync(HttpContext context) + { + if (!_options.Enabled) + return UAuthTenantContext.NotResolved(); + + var resolutionContext = + TenantResolutionContextFactory.FromHttpContext(context); + + var rawTenantId = + await _idResolver.ResolveTenantIdAsync(resolutionContext); + + if (string.IsNullOrWhiteSpace(rawTenantId)) + { + if (_options.RequireTenant) + return UAuthTenantContext.NotResolved(); + + if (_options.DefaultTenantId is null) + return UAuthTenantContext.NotResolved(); + + return UAuthTenantContext.Resolved( + Normalize(_options.DefaultTenantId)); + } + + var tenantId = Normalize(rawTenantId); + + if (!IsValid(tenantId)) + return UAuthTenantContext.NotResolved(); + + return UAuthTenantContext.Resolved(tenantId); + } + + private string Normalize(string tenantId) + { + return _options.NormalizeToLowercase + ? tenantId.ToLowerInvariant() + : tenantId; + } + + private bool IsValid(string tenantId) + { + if (!System.Text.RegularExpressions.Regex + .IsMatch(tenantId, _options.TenantIdRegex)) + return false; + + if (_options.ReservedTenantIds.Contains(tenantId)) + return false; + + return true; + } + } +} 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/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs new file mode 100644 index 00000000..d094d536 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -0,0 +1,105 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +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 + { + /// + /// Defines how UltimateAuth executes authentication flows. + /// Default is Hybrid. + /// + public UAuthMode Mode { get; set; } = UAuthMode.Hybrid; + + // ------------------------------------------------------- + // ROUTING + // ------------------------------------------------------- + + /// + /// Base API route. Default: "/auth" + /// Changing this prevents conflicts with other auth systems. + /// + public string RoutePrefix { get; set; } = "/auth"; + + + // ------------------------------------------------------- + // CORE OPTION COMPOSITION + // (Server must NOT duplicate Core options) + // ------------------------------------------------------- + + /// + /// 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 Tokens { get; set; } = new(); + + /// + /// PKCE configuration (required for WASM). + /// Fully defined in Core. + /// + public UAuthPkceOptions Pkce { get; set; } = new(); + + /// + /// Multi-tenancy behavior (resolver, normalization, etc.) + /// Fully defined in Core. + /// + public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + + + // ------------------------------------------------------- + // SERVER-ONLY BEHAVIOR + // ------------------------------------------------------- + + /// + /// Enables/disables specific endpoint groups. + /// Useful for API hardening. + /// + public bool? EnableLoginEndpoints { get; set; } = true; + public bool? EnablePkceEndpoints { get; set; } = true; + public bool? EnableTokenEndpoints { get; set; } = true; + public bool? EnableSessionEndpoints { get; set; } = true; + public bool? EnableUserInfoEndpoints { get; set; } = true; + + /// + /// 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. + /// Example: adding new routes, overriding authorization, adding filters. + /// + public Action? OnConfigureEndpoints { get; set; } + + /// + /// Allows developers to add or replace server services before DI is built. + /// Example: overriding default ILoginService. + /// + public Action? ConfigureServices { get; set; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs new file mode 100644 index 00000000..49b1d155 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs @@ -0,0 +1,75 @@ +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.RoutePrefix)) + { + return ValidateOptionsResult.Fail( + "RoutePrefix must be specified."); + } + + if (!options.RoutePrefix.StartsWith("/")) + { + return ValidateOptionsResult.Fail( + "RoutePrefix must start with '/'."); + } + + // ------------------------- + // AUTH MODE VALIDATION + // ------------------------- + if (!Enum.IsDefined(typeof(UAuthMode), options.Mode)) + { + return ValidateOptionsResult.Fail( + $"Invalid UAuthMode: {options.Mode}"); + } + + // ------------------------- + // SESSION VALIDATION + // ------------------------- + if (options.Mode != UAuthMode.PureJwt) + { + 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."); + } + } + + // ------------------------- + // MULTI-TENANT VALIDATION + // ------------------------- + if (options.MultiTenant.Enabled) + { + if (options.MultiTenant.RequireTenant && + string.IsNullOrWhiteSpace(options.MultiTenant.DefaultTenantId)) + { + // This is allowed, but warn-worthy logic + // We still allow it, middleware will reject requests + } + + if (string.IsNullOrWhiteSpace(options.MultiTenant.TenantIdRegex)) + { + return ValidateOptionsResult.Fail( + "MultiTenant.TenantIdRegex must be specified."); + } + } + + return ValidateOptionsResult.Success; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs new file mode 100644 index 00000000..9bbac33a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.Server.Options +{ + 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; } = false; + + public string HeaderName { get; set; } = "X-UAuth-Session"; + public string CookieName { get; set; } = "__uauth"; + public string QueryParameterName { get; set; } = "session_id"; + + // Precedence order + // Example: Bearer, Header, Cookie, Query + public List Order { get; set; } = new() + { + "Bearer", + "Header", + "Cookie", + "Query" + }; + } +} 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/Sessions/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs new file mode 100644 index 00000000..49293e4a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + public sealed class BearerSessionIdResolver : ISessionIdResolver + { + 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; + + return new AuthSessionId(raw); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs new file mode 100644 index 00000000..b69111b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + public sealed class CompositeSessionIdResolver : ISessionIdResolver + { + private readonly IReadOnlyList _resolvers; + + public CompositeSessionIdResolver(IEnumerable resolvers) + { + _resolvers = resolvers.ToList(); + } + + public AuthSessionId? Resolve(HttpContext context) + { + foreach (var r in _resolvers) + { + var id = r.Resolve(context); + if (id is not null) + return id; + } + + return null; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs new file mode 100644 index 00000000..cb33ac7d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + public sealed class CookieSessionIdResolver : ISessionIdResolver + { + private readonly string _cookieName; + + public CookieSessionIdResolver(string cookieName) + { + _cookieName = cookieName; + } + + public AuthSessionId? Resolve(HttpContext context) + { + if (!context.Request.Cookies.TryGetValue(_cookieName, out var raw)) + return null; + + if (string.IsNullOrWhiteSpace(raw)) + return null; + + return new AuthSessionId(raw.Trim()); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs new file mode 100644 index 00000000..aad25f48 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + public sealed class HeaderSessionIdResolver : ISessionIdResolver + { + private readonly string _headerName; + + public HeaderSessionIdResolver(string headerName) + { + _headerName = headerName; + } + + public AuthSessionId? Resolve(HttpContext context) + { + if (!context.Request.Headers.TryGetValue(_headerName, out var values)) + return null; + + var raw = values.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(raw)) + return null; + + return new AuthSessionId(raw.Trim()); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs new file mode 100644 index 00000000..bddb5067 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + public interface ISessionIdResolver + { + AuthSessionId? Resolve(HttpContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs new file mode 100644 index 00000000..358b8fd9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + /// + /// Orchestrates session, chain, and root lifecycles + /// according to UltimateAuth security rules. + /// + public interface ISessionOrchestrator + { + /// + /// Creates a new login session (initial authentication). + /// + Task> CreateLoginSessionAsync( + SessionIssueContext context); + + /// + /// Revokes a single session. + /// + Task RevokeSessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTime at); + + /// + /// Revokes all sessions of a user (global logout). + /// + Task RevokeAllSessionsAsync( + string? tenantId, + TUserId userId, + DateTime at); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs new file mode 100644 index 00000000..3019d8cf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + public sealed class QuerySessionIdResolver : ISessionIdResolver + { + private readonly string _parameterName; + + public QuerySessionIdResolver(string parameterName) + { + _parameterName = parameterName; + } + + public AuthSessionId? Resolve(HttpContext context) + { + if (!context.Request.Query.TryGetValue(_parameterName, out var values)) + return null; + + var raw = values.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(raw)) + return null; + + return new AuthSessionId(raw.Trim()); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs new file mode 100644 index 00000000..3aad3859 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + /// + /// 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 string? TenantId { get; } + + public bool IsAnonymous => SessionId is null; + + private SessionContext(AuthSessionId? sessionId, string? tenantId) + { + SessionId = sessionId; + TenantId = tenantId; + } + + public static SessionContext Anonymous() + => new(null, null); + + public static SessionContext FromSessionId( + AuthSessionId sessionId, + string? tenantId) + => new(sessionId, tenantId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs new file mode 100644 index 00000000..75afe790 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs @@ -0,0 +1,44 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + public sealed class UAuthSessionIdResolver : ISessionIdResolver + { + private readonly ISessionIdResolver _inner; + + public UAuthSessionIdResolver(IOptions options) + { + var o = options.Value; + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Bearer"] = new BearerSessionIdResolver(), + ["Header"] = new HeaderSessionIdResolver(o.HeaderName), + ["Cookie"] = new CookieSessionIdResolver(o.CookieName), + ["Query"] = new QuerySessionIdResolver(o.QueryParameterName), + }; + + var list = new List(); + + foreach (var key in o.Order) + { + if (!map.TryGetValue(key, out var resolver)) + continue; + + if (key.Equals("Bearer", StringComparison.OrdinalIgnoreCase) && !o.EnableBearer) continue; + if (key.Equals("Header", StringComparison.OrdinalIgnoreCase) && !o.EnableHeader) continue; + if (key.Equals("Cookie", StringComparison.OrdinalIgnoreCase) && !o.EnableCookie) continue; + if (key.Equals("Query", StringComparison.OrdinalIgnoreCase) && !o.EnableQuery) continue; + + list.Add(resolver); + } + + _inner = new CompositeSessionIdResolver(list); + } + + public AuthSessionId? Resolve(Microsoft.AspNetCore.Http.HttpContext context) + => _inner.Resolve(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs new file mode 100644 index 00000000..d16e03f3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs @@ -0,0 +1,235 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Issuers; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Sessions +{ + /// + /// Default UltimateAuth session store implementation. + /// Handles session, chain, and root orchestration on top of a kernel store. + /// + public sealed class UAuthSessionOrchestrator : ISessionOrchestrator + { + private readonly ISessionStoreFactory _factory; + private readonly UAuthSessionIssuer _sessionIssuer; + private readonly UAuthServerOptions _serverOptions; + + public UAuthSessionOrchestrator(ISessionStoreFactory factory, UAuthSessionIssuer sessionIssuer, UAuthServerOptions serverOptions) + { + _factory = factory; + _sessionIssuer = sessionIssuer; + _serverOptions = serverOptions; + } + + public async Task> CreateLoginSessionAsync(SessionIssueContext context) + { + var kernel = _factory.Create(context.TenantId); + + // 1️⃣ Load or create root + var root = await kernel.GetSessionRootAsync( + context.TenantId, + context.UserId); + + if (root is null) + { + root = UAuthSessionRoot.Create( + context.TenantId, + context.UserId, + context.Now); + } + else if (root.IsRevoked) + { + throw new InvalidOperationException( + "User session root is revoked."); + } + + // 2️⃣ Load or create chain (interface → concrete) + ISessionChain? loadedChain = null; + + if (context.ChainId is not null) + { + loadedChain = await kernel.GetChainAsync( + context.TenantId, + context.ChainId.Value); + } + + if (loadedChain is not null && loadedChain.IsRevoked) + { + throw new InvalidOperationException( + "Session chain is revoked."); + } + + UAuthSessionChain chain; + + if (loadedChain is null) + { + chain = UAuthSessionChain.Create( + ChainId.New(), + context.TenantId, + context.UserId, + root.SecurityVersion, + context.ClaimsSnapshot); + } + else if (loadedChain is UAuthSessionChain concreteChain) + { + chain = concreteChain; + } + else + { + throw new InvalidOperationException( + "Unsupported ISessionChain implementation. " + + "UltimateAuth requires SessionChain."); + } + + // TODO: Add cancellation token support + var issuedSession = await _sessionIssuer.IssueAsync( + context, + chain); + + // 4️⃣ Persist session + await kernel.SaveSessionAsync( + context.TenantId, + issuedSession.Session); + + // 5️⃣ Update & persist chain + var updatedChain = chain.ActivateSession( + issuedSession.Session.SessionId); + + await kernel.SaveChainAsync( + context.TenantId, + updatedChain); + + // 6️⃣ Persist root (idempotent) + await kernel.SaveSessionRootAsync( + context.TenantId, + root); + + return issuedSession; + } + + public async Task> RotateSessionAsync(string? tenantId, AuthSessionId currentSessionId, SessionIssueContext context) + { + if (_serverOptions.Mode == UAuthMode.PureJwt) + throw new InvalidOperationException( + "Session rotation is not available in PureJwt mode."); + + var kernel = _factory.Create(tenantId); + + // 1️⃣ Load current session + var currentSession = await kernel.GetSessionAsync( + tenantId, + currentSessionId); + + if (currentSession is null) + throw new InvalidOperationException("Session not found."); + + if (currentSession.IsRevoked) + throw new InvalidOperationException("Session is revoked."); + + if (currentSession.GetState(context.Now) != SessionState.Active) + throw new InvalidOperationException("Session is not active."); + + // 2️⃣ Load chain id + var chainId = await kernel.GetChainIdBySessionAsync( + tenantId, + currentSessionId); + + if (chainId is null) + throw new InvalidOperationException("Session chain not found."); + + // 3️⃣ Load chain + var loadedChain = await kernel.GetChainAsync( + tenantId, + chainId.Value); + + if (loadedChain is null || loadedChain.IsRevoked) + throw new InvalidOperationException("Session chain is revoked."); + + if (loadedChain is not UAuthSessionChain chain) + throw new InvalidOperationException( + "Unsupported ISessionChain implementation."); + + // 4️⃣ Load root + var root = await kernel.GetSessionRootAsync( + tenantId, + context.UserId); + + if (root is null || root.IsRevoked) + throw new InvalidOperationException("Session root is revoked."); + + // 5️⃣ Security version check + if (currentSession.SecurityVersionAtCreation != root.SecurityVersion) + throw new InvalidOperationException( + "Session security version mismatch."); + + // TODO: Add cancellation token support + var issuedSession = await _sessionIssuer.IssueAsync( + context, + chain); + + // 7️⃣ Persist new session + await kernel.SaveSessionAsync( + tenantId, + issuedSession.Session); + + // 8️⃣ Revoke old session + await kernel.RevokeSessionAsync( + tenantId, + currentSessionId, + context.Now); + + // 9️⃣ Activate new session in chain + var updatedChain = chain.ActivateSession( + issuedSession.Session.SessionId); + + await kernel.SaveChainAsync( + tenantId, + updatedChain); + + // 🔟 Root persistence (idempotent) + await kernel.SaveSessionRootAsync( + tenantId, + root); + + return issuedSession; + } + + public Task?> GetSessionAsync( + string? tenantId, + AuthSessionId sessionId) + { + var kernel = _factory.Create(tenantId); + return kernel.GetSessionAsync(tenantId, sessionId); + } + + public async Task RevokeSessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTime at) + { + var kernel = _factory.Create(tenantId); + await kernel.RevokeSessionAsync(tenantId, sessionId, at); + } + + public async Task RevokeAllSessionsAsync( + string? tenantId, + TUserId userId, + DateTime at) + { + var kernel = _factory.Create(tenantId); + await kernel.RevokeSessionRootAsync(tenantId, userId, at); + } + + public async Task RevokeChainAsync( + string? tenantId, + ChainId chainId, + DateTime at) + { + var kernel = _factory.Create(tenantId); + await kernel.RevokeChainAsync(tenantId, chainId, at); + } + } +} 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/UAuthSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs new file mode 100644 index 00000000..fb8dd61a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Stores +{ + /// + /// UltimateAuth default session store factory. + /// Resolves session store kernels from DI and provides them + /// to framework-level session stores. + /// + public sealed class UAuthSessionStoreFactory : ISessionStoreFactory + { + private readonly IServiceProvider _provider; + + public UAuthSessionStoreFactory(IServiceProvider provider) + { + _provider = provider; + } + + public ISessionStoreKernel Create(string? tenantId) + { + var kernel = _provider.GetService>(); + + if (kernel is null) + { + throw new InvalidOperationException( + "No ISessionStoreKernel registered. " + + "Call AddUltimateAuthServer().AddSessionStoreKernel()." + ); + } + + return kernel; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs new file mode 100644 index 00000000..05a0e5ad --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Users +{ + public interface IUserAccessor + { + Task ResolveAsync(HttpContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs new file mode 100644 index 00000000..0769479e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs @@ -0,0 +1,64 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Middlewares; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class UAuthUserAccessor : IUserAccessor + { + private readonly ISessionStore _sessionStore; + private readonly IUserStore _userStore; + + public UAuthUserAccessor( + ISessionStore sessionStore, + IUserStore userStore) + { + _sessionStore = sessionStore; + _userStore = userStore; + } + + public async Task ResolveAsync(HttpContext context) + { + var sessionCtx = context.GetSessionContext(); + + if (sessionCtx.IsAnonymous) + { + context.Items[UserMiddleware.UserContextKey] = + UserContext.Anonymous(); + return; + } + + // 🔐 Load & validate session + var session = await _sessionStore.GetSessionAsync( + sessionCtx.TenantId, + sessionCtx.SessionId!.Value); + + if (session is null || session.IsRevoked) + { + context.Items[UserMiddleware.UserContextKey] = + UserContext.Anonymous(); + return; + } + + // 👤 Load user + var user = await _userStore.FindByIdAsync(session.UserId); + + if (user is null) + { + context.Items[UserMiddleware.UserContextKey] = + UserContext.Anonymous(); + return; + } + + context.Items[UserMiddleware.UserContextKey] = + new UserContext + { + UserId = session.UserId, + User = user + }; + } + + } +} From e0b0d01dda7ba414883186e33539009af176c8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Tue, 16 Dec 2025 20:23:25 +0300 Subject: [PATCH 10/50] Build Server Project (Part 2) (#4) * Build Server Project (Part 2) * TokenService Implementation * Login Flow & Code Improvements * Folder Classification --- UltimateAuth.slnx | 2 +- .../IUAuthUserManagementService.cs | 28 ++ .../Abstractions/IUAuthUserProfileService.cs | 23 ++ ...CodeBeam.UltimateAuth.Server.Users.csproj} | 0 .../Handlers/.gitkeep | 1 - .../Users/Models/AdminUserFilter.cs | 10 + .../Users/Models/ChangePasswordRequest.cs | 13 + .../Users/Models/ConfigureMfaRequest.cs | 12 + .../Users/Models/ResetPasswordRequest.cs | 12 + .../Users/Models/UpdateProfileRequest.cs | 8 + .../Users/Models/UserDto.cs | 16 + .../Users/Models/UserProfileDto.cs | 14 + .../Abstractions/Infrastructure/IClock.cs | 11 + .../ITokenHasher.cs | 0 .../Infrastructure/IUAuthPasswordHasher.cs | 13 + .../Abstractions/Issuers/ISessionIssuer.cs | 4 +- .../Abstractions/Issuers/ITokenIssuer.cs | 14 - .../Principals/IUserAuthenticator.cs | 9 + .../{ => Principals}/IUserIdConverter.cs | 2 + .../IUserIdConverterResolver.cs | 0 .../Abstractions/Principals/IUserIdFactory.cs | 16 + .../Abstractions/Services/ISessionService.cs | 93 ----- .../Services/IUAuthFlowService.cs | 33 ++ .../Abstractions/Services/IUAuthService.cs | 15 + .../Services/IUAuthSessionService.cs | 33 ++ .../Services/IUAuthTokenService.cs | 27 ++ .../Services/IUAuthUserService.cs | 20 ++ .../Abstractions/Stores/IOpaqueTokenStore.cs | 11 + .../Abstractions/Stores/ISessionStore.cs | 2 +- .../Stores/ISessionStoreKernel.cs | 14 +- .../{IUserStore.cs => IUAuthUserStore.cs} | 32 +- .../Abstractions/Stores/IUserStoreFactory.cs | 4 +- .../Validators/ITokenValidator.cs | 16 + .../CodeBeam.UltimateAuth.Core.csproj | 6 +- .../Contexts/SessionIssueContext.cs | 23 -- .../Contexts/TokenIssueContext.cs | 16 - .../Contracts/Login/ExternalLoginRequest.cs | 11 + .../Contracts/Login/LoginContinuation.cs | 20 ++ .../Contracts/Login/LoginContinuationType.cs | 9 + .../Contracts/Login/LoginRequest.cs | 23 ++ .../Contracts/Login/LoginResult.cs | 38 ++ .../Contracts/Login/LoginStatus.cs | 9 + .../Contracts/Login/ReauthRequest.cs | 11 + .../Contracts/Login/ReauthResult.cs | 7 + .../Contracts/Logout/LogoutAllRequest.cs | 23 ++ .../Contracts/Logout/LogoutRequest.cs | 16 + .../Contracts/Mfa/BeginMfaRequest.cs | 7 + .../Contracts/Mfa/CompleteMfaRequest.cs | 8 + .../Contracts/Mfa/MfaChallengeResult.cs | 8 + .../Contracts/Pkce/PkceChallengeResult.cs | 8 + .../Contracts/Pkce/PkceConsumeRequest.cs | 7 + .../Contracts/Pkce/PkceCreateRequest.cs | 7 + .../Contracts/Pkce/PkceVerificationResult.cs | 7 + .../Contracts/Pkce/PkceVerifyRequest.cs | 8 + .../Session/AuthenticatedSessionContext.cs | 31 ++ .../Session}/IssuedSession.cs | 2 +- .../Contracts/Session}/SessionContext.cs | 2 +- .../Session/SessionRefreshRequest.cs | 8 + .../Contracts/Session/SessionRefreshResult.cs | 10 + .../Session}/SessionResult.cs | 2 +- .../Session/SessionRotationContext.cs | 15 + .../Session}/SessionStoreContext.cs | 2 +- .../Session/SessionValidationContext.cs | 12 + .../Session/SessionValidationResult.cs | 44 +++ .../Token/AccessToken.cs} | 9 +- .../Contracts/Token/AuthTokens.cs | 17 + .../Contracts/Token/OpaqueTokenRecord.cs | 18 + .../Token/RefreshToken.cs} | 4 +- .../Contracts/Token/TokenInvalidReason.cs | 16 + .../Contracts/Token/TokenIssueContext.cs | 11 + .../Contracts/Token/TokenRefreshContext.cs | 9 + .../Contracts/Token/TokenType.cs | 10 + .../Contracts/Token/TokenValidationResult.cs | 67 ++++ .../Contracts/User/RegisterUserRequest.cs | 30 ++ .../User/UserAuthenticationResult.cs | 26 ++ .../User}/UserContext.cs | 4 +- .../User/ValidateCredentialsRequest.cs | 24 ++ .../Domain/Session/ClaimsSnapshot.cs | 69 ++++ .../Domain/Session/DeviceInfo.cs | 35 +- .../Domain/Session/ISession.cs | 6 + .../Domain/Session/ISessionChain.cs | 6 +- .../Domain/Session/SessionMetadata.cs | 6 - .../Domain/Session/SessionState.cs | 41 +-- .../Domain/Session/UAuthSession.cs | 76 +++- .../Domain/Session/UAuthSessionChain.cs | 28 +- .../{Abstractions => Domain/User}/IUser.cs | 2 +- .../Domain/User/UserId.cs | 16 + .../Errors/Base/UAuthChainException.cs | 17 + .../Session/UAuthChainLinkMissingException.cs | 14 + .../UAuthSessionChainNotFoundException.cs | 12 + .../UAuthSessionChainRevokedException.cs | 14 + .../UAuthSessionDeviceMismatchException.cs | 22 ++ .../UAuthSessionExpiredException.cs | 0 .../UAuthSessionInvalidStateException.cs | 19 + .../UAuthSessionNotActiveException.cs | 0 .../Session/UAuthSessionNotFoundException.cs | 13 + .../UAuthSessionRevokedException.cs | 0 .../UAuthSessionRootRevokedException.cs | 13 + .../UAuthSessionSecurityMismatchException.cs | 17 + .../UAuthSecurityVersionMismatchException.cs | 37 -- ...UltimateAuthServiceCollectionExtensions.cs | 3 +- .../Base64Url.cs | 2 +- .../Infrastructure/GuidUserIdFactory.cs | 9 + .../RandomIdGenerator.cs | 2 +- .../Infrastructure/StringUserIdFactory.cs | 9 + .../UAuthUserIdConverter.cs | 30 +- .../UAuthUserIdConverterResolver.cs | 2 +- .../Infrastructure/UserIdFactory.cs | 10 + .../Infrastructure/UserRecord.cs | 16 + .../Models/SessionValidationResult.cs | 35 -- .../{Enums => Options}/UAuthMode.cs | 0 .../Abstractions/IDeviceResolver.cs | 13 + .../CodeBeam.UltimateAuth.Server.csproj | 8 +- .../Endpoints/DefaultLoginEndpointHandler.cs | 60 +++- .../Events/.gitkeep | 1 - .../Extensions/ClaimsSnapshotExtensions.cs | 14 + .../HttpContextSessionExtensions.cs | 11 +- .../UAuthServerServiceCollectionExtensions.cs | 10 +- .../BearerSessionIdResolver.cs | 2 +- .../CompositeSessionIdResolver.cs | 2 +- .../CookieSessionIdResolver.cs | 2 +- .../DefaultUserAuthenticator.cs | 40 +++ .../HeaderSessionIdResolver.cs | 2 +- .../ISessionIdResolver.cs | 2 +- .../Infrastructure/ISessionOrchestrator.cs | 30 ++ .../Infrastructure/ITokenIssuer.cs | 14 + .../IUserAccessor.cs | 2 +- .../QuerySessionIdResolver.cs | 2 +- .../Infrastructure/SystemClock.cs | 9 + .../Infrastructure/TokenIssuanceContext.cs | 13 + .../Infrastructure/UAuthDeviceResolver.cs | 49 +++ .../UAuthSessionIdResolver.cs | 2 +- .../UAuthSessionOrchestrator.cs | 337 ++++++++++++++++++ .../UAuthUserAccessor.cs | 10 +- .../Infrastructure/UAuthUserId.cs | 12 + .../Internal/.gitkeep | 1 - .../Issuers/UAuthSessionIssuer.cs | 17 +- .../Issuers/UAuthTokenIssuer.cs | 23 +- .../SessionResolutionMiddleware.cs | 5 +- .../Middlewares/UserMiddleware.cs | 2 +- .../MultiTenancy/DomainTenantAdapter.cs | 34 -- .../MultiTenancy/HeaderTenantAdapter.cs | 34 -- .../MultiTenancy/RouteTenantAdapter.cs | 32 -- .../Services/.gitkeep | 1 - .../Services/UAuthFlowService.cs | 149 ++++++++ .../Services/UAuthSessionService.cs | 125 +++++++ .../Services/UAuthTokenService.cs | 62 ++++ .../Services/UAuthTokenValidator.cs | 165 +++++++++ .../Services/UAuthUserService.cs | 89 +++++ .../Sessions/ISessionOrchestrator.cs | 34 -- .../Sessions/UAuthSessionOrchestrator.cs | 235 ------------ .../Stores/AspNetIdentityUserStore.cs | 38 ++ 152 files changed, 2674 insertions(+), 753 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs rename src/CodeBeam.UltimateAuth.AspNetCore/{CodeBeam.UltimateAuth.AspNetCore.csproj => CodeBeam.UltimateAuth.Server.Users.csproj} (100%) delete mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Handlers/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs create mode 100644 src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs rename src/CodeBeam.UltimateAuth.Core/Abstractions/{Issuers => Infrastructure}/ITokenHasher.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs rename src/CodeBeam.UltimateAuth.Core/Abstractions/{ => Principals}/IUserIdConverter.cs (94%) rename src/CodeBeam.UltimateAuth.Core/Abstractions/{ => Principals}/IUserIdConverterResolver.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs rename src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/{IUserStore.cs => IUAuthUserStore.cs} (74%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs rename src/CodeBeam.UltimateAuth.Core/{Contexts/Issued => Contracts/Session}/IssuedSession.cs (93%) rename src/{CodeBeam.UltimateAuth.Server/Sessions => CodeBeam.UltimateAuth.Core/Contracts/Session}/SessionContext.cs (94%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs rename src/CodeBeam.UltimateAuth.Core/{Models => Contracts/Session}/SessionResult.cs (97%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs rename src/CodeBeam.UltimateAuth.Core/{Contexts => Contracts/Session}/SessionStoreContext.cs (96%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs rename src/CodeBeam.UltimateAuth.Core/{Contexts/Issued/IssuedAccessToken.cs => Contracts/Token/AccessToken.cs} (77%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs rename src/CodeBeam.UltimateAuth.Core/{Contexts/Issued/IssuedRefreshToken.cs => Contracts/Token/RefreshToken.cs} (86%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs rename src/CodeBeam.UltimateAuth.Core/{Contexts => Contracts/User}/UserContext.cs (74%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs rename src/CodeBeam.UltimateAuth.Core/{Abstractions => Domain/User}/IUser.cs (93%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs rename src/CodeBeam.UltimateAuth.Core/Errors/{ => Session}/UAuthSessionExpiredException.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs rename src/CodeBeam.UltimateAuth.Core/Errors/{ => Session}/UAuthSessionNotActiveException.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs rename src/CodeBeam.UltimateAuth.Core/Errors/{ => Session}/UAuthSessionRevokedException.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs rename src/CodeBeam.UltimateAuth.Core/{Utilities => Infrastructure}/Base64Url.cs (96%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs rename src/CodeBeam.UltimateAuth.Core/{Utilities => Infrastructure}/RandomIdGenerator.cs (97%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs rename src/CodeBeam.UltimateAuth.Core/{Utilities => Infrastructure}/UAuthUserIdConverter.cs (84%) rename src/CodeBeam.UltimateAuth.Core/{Utilities => Infrastructure}/UAuthUserIdConverterResolver.cs (97%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs rename src/CodeBeam.UltimateAuth.Core/{Enums => Options}/UAuthMode.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Events/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs rename src/CodeBeam.UltimateAuth.Server/{Sessions => Infrastructure}/BearerSessionIdResolver.cs (92%) rename src/CodeBeam.UltimateAuth.Server/{Sessions => Infrastructure}/CompositeSessionIdResolver.cs (92%) rename src/CodeBeam.UltimateAuth.Server/{Sessions => Infrastructure}/CookieSessionIdResolver.cs (92%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs rename src/CodeBeam.UltimateAuth.Server/{Sessions => Infrastructure}/HeaderSessionIdResolver.cs (92%) rename src/CodeBeam.UltimateAuth.Server/{Sessions => Infrastructure}/ISessionIdResolver.cs (77%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs rename src/CodeBeam.UltimateAuth.Server/{Users => Infrastructure}/IUserAccessor.cs (71%) rename src/CodeBeam.UltimateAuth.Server/{Sessions => Infrastructure}/QuerySessionIdResolver.cs (92%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs rename src/CodeBeam.UltimateAuth.Server/{Sessions => Infrastructure}/UAuthSessionIdResolver.cs (96%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs rename src/CodeBeam.UltimateAuth.Server/{Users => Infrastructure}/UAuthUserAccessor.cs (85%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Internal/.gitkeep delete mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 21efc310..18a8f981 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -3,7 +3,7 @@ - + diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs new file mode 100644 index 00000000..44c57f10 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs @@ -0,0 +1,28 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + /// + /// Administrative user management operations. + /// + public interface IUAuthUserManagementService + { + Task> GetByIdAsync( + TUserId userId, + CancellationToken ct = default); + + Task>> GetAllAsync( + CancellationToken ct = default); + + Task DisableAsync( + TUserId userId, + CancellationToken ct = default); + + Task EnableAsync( + TUserId userId, + CancellationToken ct = default); + + Task ResetPasswordAsync( + TUserId userId, + ResetPasswordRequest request, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs new file mode 100644 index 00000000..68dedea7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs @@ -0,0 +1,23 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + /// + /// User self-service operations (profile, password, MFA). + /// + public interface IUAuthUserProfileService + { + Task> GetCurrentAsync( + CancellationToken ct = default); + + Task UpdateProfileAsync( + UpdateProfileRequest request, + CancellationToken ct = default); + + Task ChangePasswordAsync( + ChangePasswordRequest request, + CancellationToken ct = default); + + Task ConfigureMfaAsync( + ConfigureMfaRequest request, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.AspNetCore.csproj b/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.Server.Users.csproj similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.AspNetCore.csproj rename to src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.Server.Users.csproj 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/Users/Models/AdminUserFilter.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs new file mode 100644 index 00000000..87cec2b9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class AdminUserFilter + { + public bool? IsActive { get; init; } + public bool? IsEmailConfirmed { get; init; } + + public string? Search { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs new file mode 100644 index 00000000..b53dfb6a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class ChangePasswordRequest + { + public required string CurrentPassword { get; init; } + public required string NewPassword { get; init; } + + /// + /// If true, other sessions will be revoked. + /// + public bool RevokeOtherSessions { get; init; } = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs new file mode 100644 index 00000000..ff2b1d4c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class ConfigureMfaRequest + { + public bool Enable { get; init; } + + /// + /// Optional verification code when enabling MFA. + /// + public string? VerificationCode { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs new file mode 100644 index 00000000..672d176b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class ResetPasswordRequest + { + public required string NewPassword { get; init; } + + /// + /// If true, all active sessions will be revoked. + /// + public bool RevokeSessions { get; init; } = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs new file mode 100644 index 00000000..f7688c95 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class UpdateProfileRequest + { + public string? Username { get; init; } + public string? Email { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs new file mode 100644 index 00000000..8aa7cb51 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class UserDto + { + public required TUserId UserId { get; init; } + + public string? Username { get; init; } + public string? Email { get; init; } + + public bool IsActive { get; init; } + public bool IsEmailConfirmed { get; init; } + + public DateTime CreatedAt { get; init; } + public DateTime? LastLoginAt { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs new file mode 100644 index 00000000..9b2496ea --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class UserProfileDto + { + public required TUserId UserId { get; init; } + + public string? Username { get; init; } + public string? Email { get; init; } + + public bool IsEmailConfirmed { get; init; } + + public DateTime CreatedAt { get; init; } + } +} 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..ce4905a8 --- /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. + /// + public interface IClock + { + DateTime UtcNow { get; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenHasher.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs 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..53246c1b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs @@ -0,0 +1,13 @@ +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 password, string hash); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index f19db3eb..726bbfbf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Core.Abstractions @@ -8,6 +8,6 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// public interface ISessionIssuer { - Task> IssueAsync(SessionIssueContext context, UAuthSessionChain chain, CancellationToken cancellationToken = default); + Task> IssueAsync(AuthenticatedSessionContext context, ISessionChain chain, CancellationToken cancellationToken = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs deleted file mode 100644 index 948bf3b8..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contexts; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Issues access and refresh tokens according to the active auth mode. - /// Does not perform persistence or validation. - /// - public interface ITokenIssuer - { - Task IssueAccessTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs new file mode 100644 index 00000000..0708937c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IUserAuthenticator + { + Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs index a52f3ec9..55642158 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs @@ -31,6 +31,7 @@ public interface IUserIdConverter /// 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. @@ -41,5 +42,6 @@ public interface IUserIdConverter /// 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/IUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverterResolver.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs 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..b5d2715d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs @@ -0,0 +1,16 @@ +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/Services/ISessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs deleted file mode 100644 index c9ba2157..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs +++ /dev/null @@ -1,93 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Models; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation. - /// - /// The type used to uniquely identify the user. - public interface ISessionService - { - /// - /// Creates a new login session for the specified user. - /// - /// - /// The tenant identifier. Use null for single-tenant applications. - /// - /// The user associated with the session. - /// Information about the device initiating the session. - /// Optional metadata describing the session context. - /// The current UTC timestamp. - /// - /// A result containing the newly created session, chain, and session root. - /// - Task> CreateLoginSessionAsync(string? tenantId, TUserId userId, DeviceInfo deviceInfo, SessionMetadata? metadata, DateTime now); - - /// - /// Rotates the specified session and issues a new one while preserving the session chain. - /// - /// The tenant identifier, or null. - /// The active session identifier to be refreshed. - /// The current UTC timestamp. - /// - /// A result containing the refreshed session and updated chain. - /// - /// - /// Thrown if the session, its chain, or the user's session root is invalid. - /// - Task> RefreshSessionAsync(string? tenantId, AuthSessionId currentSessionId,DateTime now); - - /// - /// Revokes a single session, preventing further use. - /// - /// The tenant identifier, or null. - /// The session identifier to revoke. - /// The UTC timestamp of the revocation. - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); - - /// - /// Revokes an entire session chain (device-level logout). - /// - /// The tenant identifier, or null. - /// The session chain identifier to revoke. - /// The UTC timestamp of the revocation. - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); - - /// - /// Revokes the user's session root, invalidating all existing sessions across all chains. - /// - /// The tenant identifier, or null. - /// The user whose root should be revoked. - /// The UTC timestamp of the revocation. - Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at); - - /// - /// Validates a session and evaluates its current state, including expiration, revocation, and security version alignment. - /// - /// The tenant identifier, or null. - /// The session identifier to validate. - /// The current UTC timestamp. - /// - /// A detailed validation result describing the session, chain, root, - /// and computed session state. - /// - Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime now); - - /// - /// Retrieves all session chains belonging to the specified user. - /// - /// The tenant identifier, or null. - /// The user whose session chains are requested. - /// A read-only list of session chains. - Task>> GetChainsAsync(string? tenantId, TUserId userId); - - /// - /// Retrieves all sessions belonging to a specific session chain. - /// - /// The tenant identifier, or null. - /// The session chain identifier. - /// A read-only list of sessions contained within the chain. - Task>> GetSessionsAsync(string? tenantId, ChainId chainId); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs new file mode 100644 index 00000000..530bacd8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Handles authentication flows such as login, + /// logout, session refresh and reauthentication. + /// + public interface IUAuthFlowService + { + Task LoginAsync(LoginRequest request, CancellationToken ct = default); + + 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 RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default); + + Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); + + Task CreatePkceChallengeAsync(PkceCreateRequest request, CancellationToken ct = default); + + Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default); + + Task ConsumePkceAsync(PkceConsumeRequest request, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs new file mode 100644 index 00000000..3e3e0ac0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// High-level facade for UltimateAuth. + /// Provides access to authentication flows, + /// session lifecycle and user operations. + /// + public interface IUAuthService + { + IUAuthFlowService Flow { get; } + IUAuthSessionService Sessions { get; } + IUAuthTokenService Tokens { get; } + IUAuthUserService Users { get; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs new file mode 100644 index 00000000..25ad4f5b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation. + /// + /// The type used to uniquely identify the user. + public interface IUAuthSessionService + { + Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + + Task>> GetChainsAsync(string? tenantId, TUserId userId); + + Task>> GetSessionsAsync(string? tenantId, ChainId chainId); + + Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId); + + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); + + Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId); + + Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTime at); + + // Hard revoke - admin + Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at); + + Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs new file mode 100644 index 00000000..bca0bd6c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Issues, refreshes and validates access and refresh tokens. + /// Stateless or hybrid depending on auth mode. + /// + public interface IUAuthTokenService + { + /// + /// Issues access (and optionally refresh) tokens + /// for a validated session. + /// + Task CreateTokensAsync(TokenIssueContext context, CancellationToken cancellationToken = default); + + /// + /// Refreshes tokens using a refresh token. + /// + Task RefreshAsync(TokenRefreshContext context, CancellationToken cancellationToken = default); + + /// + /// Validates an access token (JWT or opaque). + /// + Task> ValidateAsync(string token, TokenType type, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs new file mode 100644 index 00000000..ee74667a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Minimal user operations required for authentication. + /// Does NOT include role or permission management. + /// For user management, CodeBeam.UltimateAuth.Users package is recommended. + /// + public interface IUAuthUserService + { + Task RegisterAsync(RegisterUserRequest request, CancellationToken cancellationToken = default); + + Task DeleteAsync(TUserId userId, CancellationToken cancellationToken = default); + + Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); + + Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs new file mode 100644 index 00000000..2f5e141f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IOpaqueTokenStore + { + Task FindByHashAsync( + string tokenHash, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index e25ba9c1..5b65e7d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Core.Abstractions diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs index 9d8763b5..d398eb15 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs @@ -56,13 +56,6 @@ public interface ISessionStoreKernel /// The chain to save. Task SaveChainAsync(string? tenantId, ISessionChain chain); - /// - /// Updates an existing session chain, typically after session rotation or revocation. Implementations must preserve atomicity. - /// - /// The tenant identifier, or null. - /// The updated session chain. - Task UpdateChainAsync(string? tenantId, ISessionChain chain); - /// /// Marks the entire session chain as revoked, invalidating all associated sessions for the device or app family. /// @@ -135,6 +128,11 @@ public interface ISessionStoreKernel /// The session identifier. /// The chain identifier or null. Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId); - } + /// + /// Executes multiple store operations as a single atomic unit. + /// Implementations must ensure transactional consistency where supported. + /// + Task ExecuteAsync(Func action); + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs similarity index 74% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs index fcb268b1..53f78c59 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs @@ -1,18 +1,20 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Core.Abstractions { /// /// Provides minimal user lookup and security metadata required for authentication. /// This store does not manage user creation, claims, or profile data — these belong /// to higher-level application services outside UltimateAuth. /// - public interface IUserStore + public interface IUAuthUserStore { - /// - /// Retrieves a user by identifier. Returns null if no such user exists. - /// - /// The identifier of the user. - /// The user instance or null if not found. - Task?> FindByIdAsync(TUserId userId); + Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken token = default); + + Task?> FindByUsernameAsync(string? tenantId, + string username, + CancellationToken ct = default); /// /// Retrieves a user by a login credential such as username or email. @@ -33,7 +35,7 @@ public interface IUserStore /// /// Updates the password hash for the specified user. This method is invoked by - /// password management services and not by . + /// password management services and not by . /// /// The user identifier. /// The new password hash value. @@ -54,5 +56,17 @@ public interface IUserStore /// /// The user identifier. Task IncrementSecurityVersionAsync(TUserId userId); + + Task ExistsByUsernameAsync( + string username, + CancellationToken ct = default); + + Task CreateAsync( + UserRecord user, + CancellationToken ct = default); + + Task DeleteAsync( + TUserId userId, + CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs index 06e7a0d7..23f85519 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs @@ -16,11 +16,11 @@ public interface IUserStoreFactory /// in single-tenant deployments. /// /// - /// An implementation capable of user lookup and security metadata retrieval. + /// An implementation capable of user lookup and security metadata retrieval. /// /// /// Thrown if no user store implementation has been registered for the given user ID type. /// - IUserStore Create(string tenantId); + IUAuthUserStore Create(string tenantId); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs new file mode 100644 index 00000000..d2010fb8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Validates access tokens (JWT or opaque) and resolves + /// the authenticated user context. + /// + public interface ITokenValidator + { + Task> ValidateAsync( + string token, + TokenType type, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj index 488b584d..c03f964f 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -8,9 +8,9 @@ - - - + + + diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs deleted file mode 100644 index d75473c2..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Contexts -{ - /// - /// Represents the context in which a session is issued - /// (login, refresh, reauthentication). - /// - public sealed class SessionIssueContext - { - public required TUserId UserId { get; init; } - public string? TenantId { get; init; } - - public required long SecurityVersion { get; init; } - - public DeviceInfo Device { get; init; } - public IReadOnlyDictionary? ClaimsSnapshot { get; init; } - - public DateTime Now { get; init; } = DateTime.UtcNow; - - public ChainId? ChainId { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs deleted file mode 100644 index 782d6a40..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Claims; - -namespace CodeBeam.UltimateAuth.Core.Contexts -{ - public sealed class TokenIssueContext - { - public required string UserId { get; init; } - public required string TenantId { get; init; } - - public IReadOnlyCollection Claims { get; init; } = Array.Empty(); - - public string? SessionId { get; init; } - - public DateTimeOffset IssuedAt { get; init; } = DateTimeOffset.UtcNow; - } -} 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..6126e7d7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record ExternalLoginRequest + { + public string? TenantId { get; init; } + public string Provider { get; init; } = default!; + public string ExternalToken { get; init; } = default!; + public string? DeviceId { 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..ec5fb02b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs @@ -0,0 +1,20 @@ +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..662fbef9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum LoginContinuationType + { + Mfa, + Pkce, + External + } +} 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..97a75f0c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record LoginRequest + { + public string? TenantId { get; init; } + public string Identifier { get; init; } = default!; // username, email etc. + public string Secret { get; init; } = default!; // password + public DateTime? At { get; init; } + public DeviceInfo DeviceInfo { get; init; } + 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; } + + // Optional + public ChainId? ChainId { 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..cc0d44fe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +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 RefreshToken? RefreshToken { get; init; } + public LoginContinuation? Continuation { get; init; } + + // Helpers + 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() => new() { Status = LoginStatus.Failed }; + + public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null) + => new() + { + Status = LoginStatus.Success, + SessionId = sessionId, + AccessToken = tokens?.AccessToken, + RefreshToken = tokens?.RefreshToken + }; + + 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..94a3902c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum LoginStatus + { + Success, + RequiresContinuation, + Failed + } +} 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..b1d25650 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record ReauthRequest + { + public string? TenantId { get; init; } + public AuthSessionId SessionId { get; init; } + public string Secret { get; init; } = default!; + } +} 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..d14eb108 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record ReauthResult + { + public bool Success { get; init; } + } +} 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..7bfa2da5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class LogoutAllRequest + { + public string? TenantId { get; init; } + + /// + /// 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; } + + public DateTime? At { get; init; } + } + +} 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..90e69730 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record LogoutRequest + { + public string? TenantId { get; init; } + public AuthSessionId SessionId { get; init; } + + /// + /// Optional logical timestamp for the logout operation. + /// If not provided, the flow service will use DateTime.UtcNow. + /// + public DateTime? At { 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..86af91a4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record BeginMfaRequest + { + public string MfaToken { get; init; } = default!; + } +} 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..5d575d0b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record CompleteMfaRequest + { + public string ChallengeId { get; init; } = default!; + public string Code { get; init; } = default!; + } +} 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..9bb085c8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record MfaChallengeResult + { + public string ChallengeId { get; init; } = default!; + public string Method { get; init; } = default!; // totp, sms, email etc. + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs new file mode 100644 index 00000000..1a4d986c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceChallengeResult + { + public string Challenge { get; init; } = default!; + public string Method { get; init; } = "S256"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs new file mode 100644 index 00000000..153e865b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceConsumeRequest + { + public string Challenge { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs new file mode 100644 index 00000000..bd8eb88e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceCreateRequest + { + public string ClientId { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs new file mode 100644 index 00000000..c094b0a3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceVerificationResult + { + public bool IsValid { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs new file mode 100644 index 00000000..9a1d588d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceVerifyRequest + { + public string Challenge { get; init; } = default!; + public string Verifier { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs new file mode 100644 index 00000000..6422fbb2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + /// + /// Represents the context in which a session is issued + /// (login, refresh, reauthentication). + /// + public sealed class AuthenticatedSessionContext + { + public string? TenantId { get; init; } + public required TUserId UserId { get; init; } + public DeviceInfo DeviceInfo { get; init; } + public DateTime Now { get; init; } + public ClaimsSnapshot? Claims { get; init; } + public SessionMetadata Metadata { get; init; } + + /// + /// Optional chain identifier. + /// If null, a new chain will be created. + /// If provided, session will be issued under the existing chain. + /// + public ChainId? 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/Contexts/Issued/IssuedSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedSession.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs index 157663a4..cc2f0f82 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Represents the result of a session issuance operation. diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs index 3aad3859..03af37ba 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Lightweight session context resolved from the incoming request. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs new file mode 100644 index 00000000..9343883f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record SessionRefreshRequest + { + public string? TenantId { get; init; } + public string RefreshToken { get; init; } = default!; + } +} 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..d207f59d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record SessionRefreshResult + { + public AccessToken AccessToken { get; init; } = default!; + public RefreshToken? RefreshToken { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs index 5dbfb062..cb43f4e4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Models +namespace CodeBeam.UltimateAuth.Core.Contracts { // TODO: IsNewChain, IsNewRoot flags? /// 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..2510afaa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record SessionRotationContext + { + public string? TenantId { get; init; } + public AuthSessionId CurrentSessionId { get; init; } + public TUserId UserId { get; init; } + public DateTime Now { get; init; } + public DeviceInfo Device { get; init; } + public ClaimsSnapshot Claims { get; init; } + public SessionMetadata Metadata { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs index fa5a4267..78910d49 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Context information required by the session store when 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..aa8b3dd7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record SessionValidationContext + { + public string? TenantId { get; init; } + public AuthSessionId SessionId { get; init; } + public DateTime Now { get; init; } + public DeviceInfo 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..26e9020b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -0,0 +1,44 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class SessionValidationResult + { + public SessionState State { get; } + public ISession? Session { get; } + public ISessionChain? Chain { get; } + public ISessionRoot? Root { get; } + + private SessionValidationResult( + SessionState state, + ISession? session, + ISessionChain? chain, + ISessionRoot? root) + { + State = state; + Session = session; + Chain = chain; + Root = root; + } + + public bool IsValid => State == SessionState.Active; + + public static SessionValidationResult Active( + ISession session, + ISessionChain chain, + ISessionRoot root) + => new( + SessionState.Active, + session, + chain, + root); + + public static SessionValidationResult Invalid( + SessionState state) + => new( + state, + session: null, + chain: null, + root: null); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs index 32d37e6b..32843501 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs @@ -1,20 +1,21 @@ -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Represents an issued access token (JWT or opaque). /// - public sealed class IssuedAccessToken + 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 required string TokenType { get; init; } + public TokenType Type { get; init; } /// /// Expiration time of the token. @@ -25,5 +26,7 @@ public sealed class IssuedAccessToken /// 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..344fedd9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs @@ -0,0 +1,17 @@ +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 AccessToken AccessToken { get; init; } = default!; + + public RefreshToken? RefreshToken { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs new file mode 100644 index 00000000..ed13a6ae --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class OpaqueTokenRecord + { + public string TokenHash { get; init; } = default!; + public string UserId { get; init; } = default!; + public string? TenantId { get; init; } + public AuthSessionId? SessionId { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public bool IsRevoked { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + public IReadOnlyCollection Claims { get; init; } = Array.Empty(); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs similarity index 86% rename from src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs index 1f648048..1e9d87a0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs @@ -1,10 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Represents an issued refresh token. /// Always opaque and hashed at rest. /// - public sealed class IssuedRefreshToken + public sealed class RefreshToken { /// /// Plain refresh token value (returned to client once). 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..96ce78b1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum TokenInvalidReason + { + Invalid, + Expired, + Revoked, + Malformed, + SignatureInvalid, + AudienceMismatch, + IssuerMismatch, + MissingSubject, + Unknown, + NotImplemented + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs new file mode 100644 index 00000000..ad3dc768 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record TokenIssueContext + { + public string? TenantId { get; init; } + public ISession Session { get; init; } = default!; + public DateTime Now { 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..95074428 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record TokenRefreshContext + { + public string? TenantId { 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..dc94f72e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum TokenType + { + Opaque, + Jwt, + Unknown + } + +} 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..b8e7c29b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs @@ -0,0 +1,67 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record TokenValidationResult + { + public bool IsValid { get; init; } + public TokenType Type { get; init; } + public string? TenantId { 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 DateTime? ExpiresAt { get; set; } + + private TokenValidationResult( + bool isValid, + TokenType type, + string? tenantId, + TUserId? userId, + AuthSessionId? sessionId, + IReadOnlyCollection? claims, + TokenInvalidReason? invalidReason, + DateTime? expiresAt + ) + { + IsValid = isValid; + TenantId = tenantId; + UserId = userId; + SessionId = sessionId; + Claims = claims ?? Array.Empty(); + InvalidReason = invalidReason; + ExpiresAt = expiresAt; + } + + public static TokenValidationResult Valid( + TokenType type, + string? tenantId, + TUserId userId, + AuthSessionId? sessionId, + IReadOnlyCollection claims, + DateTime? expiresAt) + => new( + isValid: true, + type, + tenantId, + userId, + sessionId, + claims, + invalidReason: null, + expiresAt + ); + + public static TokenValidationResult Invalid(TokenType type, TokenInvalidReason reason) + => new( + isValid: false, + type, + tenantId: null, + userId: default, + sessionId: null, + claims: null, + invalidReason: reason, + expiresAt: null + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs new file mode 100644 index 00000000..a5565b97 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs @@ -0,0 +1,30 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + /// + /// Request to register a new user with credentials. + /// + public sealed class 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 string? TenantId { get; init; } + + /// + /// Optional initial claims or metadata. + /// + public IReadOnlyDictionary? Metadata { get; init; } + } +} 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..64e2c34b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs @@ -0,0 +1,26 @@ +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/Contexts/UserContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs similarity index 74% rename from src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs index 34f30a24..0c87265d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs @@ -1,6 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed class UserContext { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs new file mode 100644 index 00000000..fc1dd7ef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + /// + /// Request to validate user credentials. + /// Used during login flows. + /// + public sealed class ValidateCredentialsRequest + { + /// + /// User identifier (same value used during registration). + /// + public required string Identifier { get; init; } + + /// + /// Plain-text password provided by the user. + /// + public required string Password { get; init; } + + /// + /// Optional tenant identifier. + /// + public string? TenantId { get; init; } + } +} 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..9cf5ad5a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -0,0 +1,69 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public sealed class ClaimsSnapshot + { + private readonly IReadOnlyDictionary _claims; + + public ClaimsSnapshot(IReadOnlyDictionary claims) + { + _claims = new Dictionary(claims); + } + + public IReadOnlyDictionary AsDictionary() => _claims; + + public bool TryGet(string type, out string value) => _claims.TryGetValue(type, out value); + + public string? Get(string type) + => _claims.TryGetValue(type, out var value) + ? value + : null; + + public static ClaimsSnapshot Empty { get; } = new ClaimsSnapshot(new Dictionary()); + + public override bool Equals(object? obj) + { + if (obj is not ClaimsSnapshot other) + return false; + + if (_claims.Count != other._claims.Count) + return false; + + foreach (var kv in _claims) + { + if (!other._claims.TryGetValue(kv.Key, out var v)) + return false; + + if (!string.Equals(kv.Value, v, StringComparison.Ordinal)) + return false; + } + + return true; + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + foreach (var kv in _claims.OrderBy(x => x.Key)) + { + hash = hash * 23 + kv.Key.GetHashCode(); + hash = hash * 23 + kv.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) + dict[type] = value; + + return new ClaimsSnapshot(dict); + } + + // TODO: Add ToClaimsPrincipal and FromClaimsPrincipal methods + + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs index ca204c3a..c70c7e5e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs @@ -7,6 +7,11 @@ /// public sealed class DeviceInfo { + /// + /// Gets the unique identifier for the device. + /// + public string DeviceId { get; init; } = default!; + /// /// Gets the high-level platform identifier, such as web, mobile, /// tablet or iot. @@ -55,6 +60,34 @@ public sealed class DeviceInfo /// Gets optional custom metadata supplied by the application. /// Allows additional device attributes not covered by standard fields. /// - public Dictionary? Custom { get; init; } + public Dictionary? Custom { get; init; } + + public static DeviceInfo Unknown { get; } = new() + { + DeviceId = "unknown", + Platform = null, + Browser = null, + IpAddress = null, + UserAgent = null, + IsTrusted = null + }; + + /// + /// Determines whether the current device information matches the specified device information based on device + /// identifiers. + /// + /// The device information to compare with the current instance. Cannot be null. + /// true if the device identifiers are equal; otherwise, false. + public bool Matches(DeviceInfo other) + { + if (other is null) + return false; + + if (DeviceId != other.DeviceId) + return false; + + // TODO: UA / IP drift policy + return true; + } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs index 349ae7eb..232e13ac 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -56,6 +56,8 @@ public interface ISession /// DeviceInfo Device { get; } + ClaimsSnapshot Claims { get; } + /// /// Gets session-scoped metadata used for application-specific extensions, /// such as tenant data, app version, locale, or CSRF tokens. @@ -69,5 +71,9 @@ public interface ISession /// Current timestamp used for comparisons. /// The evaluated of this session. SessionState GetState(DateTime now); + + bool ShouldUpdateLastSeen(DateTime now); + ISession Touch(DateTime now); + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs index e408b171..358beb85 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs @@ -35,7 +35,7 @@ public interface ISessionChain /// Useful for offline clients, WASM apps, and environments where /// full user lookup cannot be performed on each request. /// - IReadOnlyDictionary? ClaimsSnapshot { get; } + ClaimsSnapshot ClaimsSnapshot { get; } /// /// Gets the identifier of the currently active authentication session, if one exists. @@ -53,6 +53,10 @@ public interface ISessionChain /// Gets the timestamp when the chain was revoked, if applicable. /// DateTime? RevokedAt { get; } + + ISessionChain AttachSession(AuthSessionId sessionId); + ISessionChain RotateSession(AuthSessionId sessionId); + ISessionChain Revoke(DateTime at); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs index 20fe7fe1..ca81551a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs @@ -27,12 +27,6 @@ public sealed class SessionMetadata /// public string? Locale { get; init; } - /// - /// Gets the tenant identifier attached to this session, if applicable. - /// This value may override or complement root-level multi-tenant resolution. - /// - public string? TenantId { get; init; } - /// /// Gets a Cross-Site Request Forgery token or other session-scoped secret /// used for request integrity validation in web applications. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs index 0faaf751..95a6af0a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs @@ -6,39 +6,12 @@ /// public enum SessionState { - /// - /// The session is valid, not expired, not revoked, and its security version - /// matches the user's current security version. - /// - Active = 0, - - /// - /// The session has passed its expiration time and is no longer valid. - /// - Expired = 1, - - /// - /// The session was explicitly revoked by user action or administrative control. - /// - Revoked = 2, - - /// - /// The session's parent chain has been revoked, typically representing a - /// device-level logout or device ban. - /// - ChainRevoked = 3, - - /// - /// The user's entire session root has been revoked. This invalidates all - /// chains and sessions immediately across all devices. - /// - RootRevoked = 4, - - /// - /// The session's stored SecurityVersionAtCreation does not match the user's - /// current security version, indicating a password reset, MFA reset, - /// or other critical security event. - /// - SecurityVersionMismatch = 5 + Active, + Expired, + Revoked, + NotFound, + Invalid, + SecurityMismatch, + DeviceMismatch } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 07dab4e4..a56fcd2a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -12,20 +12,22 @@ public sealed class UAuthSession : ISession public DateTime? RevokedAt { get; } public long SecurityVersionAtCreation { get; } public DeviceInfo Device { get; } + public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } private UAuthSession( - AuthSessionId sessionId, - string? tenantId, - TUserId userId, - DateTime createdAt, - DateTime expiresAt, - DateTime? lastSeenAt, - bool isRevoked, - DateTime? revokedAt, - long securityVersionAtCreation, - DeviceInfo device, - SessionMetadata metadata) + AuthSessionId sessionId, + string? tenantId, + TUserId userId, + DateTime createdAt, + DateTime expiresAt, + DateTime? lastSeenAt, + bool isRevoked, + DateTime? revokedAt, + long securityVersionAtCreation, + DeviceInfo device, + ClaimsSnapshot claims, + SessionMetadata metadata) { SessionId = sessionId; TenantId = tenantId; @@ -37,6 +39,7 @@ private UAuthSession( RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; Device = device; + Claims = claims; Metadata = metadata; } @@ -46,11 +49,11 @@ public static UAuthSession Create( TUserId userId, DateTime now, DateTime expiresAt, - long securityVersion, DeviceInfo device, + ClaimsSnapshot claims, SessionMetadata metadata) { - return new UAuthSession( + return new( sessionId, tenantId, userId, @@ -59,25 +62,59 @@ public static UAuthSession Create( lastSeenAt: now, isRevoked: false, revokedAt: null, - securityVersionAtCreation: securityVersion, + securityVersionAtCreation: 0, device: device, + claims: claims, metadata: metadata ); } - public UAuthSession WithLastSeen(DateTime now) + public UAuthSession WithSecurityVersion(long version) { + if (SecurityVersionAtCreation == version) + return this; + return new UAuthSession( SessionId, TenantId, UserId, CreatedAt, ExpiresAt, - lastSeenAt: now, + LastSeenAt, + IsRevoked, + RevokedAt, + version, + Device, + Claims, + Metadata + ); + } + + public bool ShouldUpdateLastSeen(DateTime now) + { + if (LastSeenAt is null) + return true; + + return (now - LastSeenAt.Value) >= TimeSpan.FromMinutes(1); + } + + public ISession Touch(DateTime now) + { + if (!ShouldUpdateLastSeen(now)) + return this; + + return new UAuthSession( + SessionId, + TenantId, + UserId, + CreatedAt, + ExpiresAt, + now, IsRevoked, RevokedAt, SecurityVersionAtCreation, Device, + Claims, Metadata ); } @@ -86,17 +123,18 @@ public UAuthSession Revoke(DateTime at) { if (IsRevoked) return this; - return new UAuthSession( + return new( SessionId, TenantId, UserId, CreatedAt, ExpiresAt, LastSeenAt, - isRevoked: true, - revokedAt: at, + true, + at, SecurityVersionAtCreation, Device, + Claims, Metadata ); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index c7758776..1afa3aa1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -7,7 +7,7 @@ public sealed class UAuthSessionChain : ISessionChain public TUserId UserId { get; } public int RotationCount { get; } public long SecurityVersionAtCreation { get; } - public IReadOnlyDictionary? ClaimsSnapshot { get; } + public ClaimsSnapshot ClaimsSnapshot { get; } public AuthSessionId? ActiveSessionId { get; } public bool IsRevoked { get; } public DateTime? RevokedAt { get; } @@ -18,7 +18,7 @@ private UAuthSessionChain( TUserId userId, int rotationCount, long securityVersionAtCreation, - IReadOnlyDictionary? claimsSnapshot, + ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, bool isRevoked, DateTime? revokedAt) @@ -39,7 +39,7 @@ public static UAuthSessionChain Create( string? tenantId, TUserId userId, long securityVersion, - IReadOnlyDictionary? claimsSnapshot = null) + ClaimsSnapshot claimsSnapshot) { return new UAuthSessionChain( chainId, @@ -54,7 +54,25 @@ public static UAuthSessionChain Create( ); } - public UAuthSessionChain ActivateSession(AuthSessionId sessionId) + public ISessionChain AttachSession(AuthSessionId sessionId) + { + if (IsRevoked) + return this; + + return new UAuthSessionChain( + ChainId, + TenantId, + UserId, + RotationCount, // Unchanged on first attach + SecurityVersionAtCreation, + ClaimsSnapshot, + activeSessionId: sessionId, + isRevoked: false, + revokedAt: null + ); + } + + public ISessionChain RotateSession(AuthSessionId sessionId) { if (IsRevoked) return this; @@ -72,7 +90,7 @@ public UAuthSessionChain ActivateSession(AuthSessionId sessionId) ); } - public UAuthSessionChain Revoke(DateTime at) + public ISessionChain Revoke(DateTime at) { if (IsRevoked) return this; diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs index b3fe9709..5222ca98 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Domain { /// /// Represents the minimal user abstraction required by UltimateAuth. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs new file mode 100644 index 00000000..962885f9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Strongly typed identifier for a user. + /// Default user id implementation for UltimateAuth. + /// + public readonly record struct UserId(string Value) + { + public override string ToString() => Value; + + public static UserId New() => new(Guid.NewGuid().ToString("N")); + + public static implicit operator string(UserId id) => id.Value; + public static implicit operator UserId(string value) => new(value); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs new file mode 100644 index 00000000..22386973 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public abstract class UAuthChainException : UAuthDomainException + { + public ChainId ChainId { get; } + + protected UAuthChainException( + ChainId chainId, + string message) + : base(message) + { + ChainId = chainId; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs new file mode 100644 index 00000000..68ce8bd5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionChainLinkMissingException : UAuthSessionException + { + public UAuthSessionChainLinkMissingException(AuthSessionId sessionId) + : base( + sessionId, + $"Session '{sessionId}' is not associated with any session chain.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs new file mode 100644 index 00000000..758b7ef0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionChainNotFoundException : UAuthChainException + { + public UAuthSessionChainNotFoundException(ChainId chainId) + : base(chainId, $"Session chain '{chainId}' was not found.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs new file mode 100644 index 00000000..c755b931 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionChainRevokedException : UAuthChainException + { + public ChainId ChainId { get; } + + public UAuthSessionChainRevokedException(ChainId chainId) + : base(chainId, $"Session chain '{chainId}' has been revoked.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs new file mode 100644 index 00000000..bb8660f1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionDeviceMismatchException : UAuthSessionException + { + public DeviceInfo Expected { get; } + public DeviceInfo Actual { get; } + + public UAuthSessionDeviceMismatchException( + AuthSessionId sessionId, + DeviceInfo expected, + DeviceInfo actual) + : base( + sessionId, + $"Session '{sessionId}' device mismatch detected.") + { + Expected = expected; + Actual = actual; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionExpiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionExpiredException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs new file mode 100644 index 00000000..bd396bd3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionInvalidStateException : UAuthSessionException + { + public SessionState State { get; } + + public UAuthSessionInvalidStateException( + AuthSessionId sessionId, + SessionState state) + : base( + sessionId, + $"Session '{sessionId}' is in invalid state '{state}'.") + { + State = state; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionNotActiveException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionNotActiveException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs new file mode 100644 index 00000000..8cc0a593 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionNotFoundException + : UAuthSessionException + { + public UAuthSessionNotFoundException(AuthSessionId sessionId) + : base(sessionId, $"Session '{sessionId}' was not found.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionRevokedException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs new file mode 100644 index 00000000..f1c89786 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionRootRevokedException : Exception + { + public object UserId { get; } + + public UAuthSessionRootRevokedException(object userId) + : base("All sessions for the user have been revoked.") + { + UserId = userId; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs new file mode 100644 index 00000000..9ba17f19 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionSecurityMismatchException : UAuthSessionException +{ + public long CurrentSecurityVersion { get; } + + public UAuthSessionSecurityMismatchException( + AuthSessionId sessionId, + long currentSecurityVersion) + : base( + sessionId, + $"Session '{sessionId}' is invalid due to security version mismatch.") + { + CurrentSecurityVersion = currentSecurityVersion; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs deleted file mode 100644 index b312ddef..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors -{ - /// - /// Represents a domain-level authentication failure caused by a mismatch - /// between the session's stored security version and the user's current - /// security version. - /// A mismatch indicates that a critical security event has occurred - /// after the session was created—such as a password reset, MFA reset, - /// account recovery, or other action requiring all prior sessions - /// to be invalidated. - /// - public sealed class UAuthSecurityVersionMismatchException : UAuthDomainException - { - /// - /// Gets the security version captured when the session was created. - /// - public long SessionVersion { get; } - - /// - /// Gets the user's current security version, which has increased - /// since the session was issued. - /// - public long UserVersion { get; } - - /// - /// Initializes a new instance of the class - /// using the session's stored version and the user's current version. - /// - /// The security version value stored in the session. - /// The user's current security version. - public UAuthSecurityVersionMismatchException(long sessionVersion, long userVersion) : base($"Security version mismatch. Session={sessionVersion}, User={userVersion}") - { - SessionVersion = sessionVersion; - UserVersion = userVersion; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs index a8f872d0..315d384f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Core.Utilities; +using CodeBeam.UltimateAuth.Core.Infrastructure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Core.Extensions diff --git a/src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs index 8b120f0c..48fb6c83 100644 --- a/src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Utilities +namespace CodeBeam.UltimateAuth.Core.Infrastructure { /// /// Provides Base64 URL-safe encoding and decoding utilities. diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs new file mode 100644 index 00000000..afe906be --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs @@ -0,0 +1,9 @@ +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/Utilities/RandomIdGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Core/Utilities/RandomIdGenerator.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs index 5c63d26d..b2faa234 100644 --- a/src/CodeBeam.UltimateAuth.Core/Utilities/RandomIdGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography; -namespace CodeBeam.UltimateAuth.Core.Utilities +namespace CodeBeam.UltimateAuth.Core.Infrastructure { /// /// Provides cryptographically secure random ID generation. diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs new file mode 100644 index 00000000..a622edfa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs @@ -0,0 +1,9 @@ +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/Utilities/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs index e0ebae56..01085ffe 100644 --- a/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs @@ -4,7 +4,7 @@ using System.Text; using System.Text.Json; -namespace CodeBeam.UltimateAuth.Core.Utilities +namespace CodeBeam.UltimateAuth.Core.Infrastructure { /// /// Default implementation of that provides @@ -71,6 +71,20 @@ public TUserId FromString(string value) }; } + 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. /// @@ -79,5 +93,19 @@ public TUserId FromString(string value) 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/Utilities/UAuthUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs index 8e2f0797..8ac22a10 100644 --- a/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Core.Utilities +namespace CodeBeam.UltimateAuth.Core.Infrastructure { /// /// Resolves instances from the DI container. diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs new file mode 100644 index 00000000..7a14b542 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class UserIdFactory : IUserIdFactory + { + public UserId Create() => new UserId(Guid.NewGuid().ToString("N")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs new file mode 100644 index 00000000..3f80324d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class UserRecord + { + public required TUserId Id { get; init; } + public required string Username { get; init; } + public required string PasswordHash { get; init; } + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; + public bool RequiresMfa { get; init; } + public bool IsActive { get; init; } = true; + public DateTime CreatedAt { get; init; } + public bool IsDeleted { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs deleted file mode 100644 index 31560b15..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Models -{ - /// - /// Represents the outcome of validating a session, including the resolved session, - /// its chain and root structures, and the computed validation state. - /// - /// - /// Session, Chain and Root may be null if validation fails or if the session - /// does not exist. State always indicates the final resolved status. - /// - public sealed class SessionValidationResult - { - /// - /// The resolved session instance, or null if the session was not found. - /// - public ISession? Session { get; init; } - - /// - /// The session chain that owns the session, or null if unavailable. - /// - public ISessionChain? Chain { get; init; } - - /// - /// The session root associated with the user, or null if unavailable. - /// - public ISessionRoot? Root { get; init; } - - /// - /// The final computed validation state for the session. - /// - public SessionState State { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Enums/UAuthMode.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Enums/UAuthMode.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs new file mode 100644 index 00000000..adb05d43 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Abstractions +{ + /// + /// Resolves device and client metadata from the current HTTP context. + /// + public interface IDeviceResolver + { + DeviceInfo Resolve(HttpContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index d4e9395d..957c7210 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -8,15 +8,19 @@ - + - + + + + + diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index 2b94841b..cb63cef0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -1,12 +1,60 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.MultiTenancy; +using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler { - public class DefaultLoginEndpointHandler : ILoginEndpointHandler + private readonly IUAuthFlowService _flow; + private readonly IDeviceResolver _deviceResolver; + private readonly ITenantResolver _tenantResolver; + private readonly IClock _clock; + + public DefaultLoginEndpointHandler( + IUAuthFlowService flow, + IDeviceResolver deviceResolver, + ITenantResolver tenantResolver, + IClock clock) + { + _flow = flow; + _deviceResolver = deviceResolver; + _tenantResolver = tenantResolver; + _clock = clock; + } + + public async Task LoginAsync(HttpContext ctx) { - public Task LoginAsync(HttpContext ctx) + var request = await ctx.Request.ReadFromJsonAsync(); + if (request is null) + return Results.BadRequest("Invalid login request."); + + var tenantCtx = await _tenantResolver.ResolveAsync(ctx); + + var flowRequest = request with { - return Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); - } + TenantId = tenantCtx.TenantId, + At = _clock.UtcNow, + DeviceInfo = _deviceResolver.Resolve(ctx) + }; + + var result = await _flow.LoginAsync(flowRequest, ctx.RequestAborted); + + return result.Status switch + { + LoginStatus.Success => Results.Ok(new + { + sessionId = result.SessionId, + accessToken = result.AccessToken, + refreshToken = result.RefreshToken + }), + + LoginStatus.RequiresContinuation => Results.Accepted(null, result.Continuation), + + LoginStatus.Failed => Results.Unauthorized(), + + _ => Results.StatusCode(500) + }; } } 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/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs new file mode 100644 index 00000000..bfdbee3d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs @@ -0,0 +1,14 @@ +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/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs index 474f7831..4d3063fa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs @@ -1,17 +1,14 @@ -using CodeBeam.UltimateAuth.Server.Middlewares; -using CodeBeam.UltimateAuth.Server.Sessions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions { public static class HttpContextSessionExtensions { - public static SessionContext GetSessionContext( - this HttpContext context) + public static SessionContext GetSessionContext(this HttpContext context) { - if (context.Items.TryGetValue( - SessionResolutionMiddleware.SessionContextKey, - out var value) + if (context.Items.TryGetValue(SessionResolutionMiddleware.SessionContextKey, out var value) && value is SessionContext session) { return session; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index 8520014b..c34701db 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -1,10 +1,13 @@ -using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Issuers; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Users; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -84,6 +87,11 @@ private static IServiceCollection AddUltimateAuthServerInternal( services.TryAddScoped(); + services.AddScoped(typeof(IUAuthFlowService), typeof(UAuthFlowService<>)); + services.AddScoped(typeof(IUAuthSessionService<>), typeof(UAuthSessionService<>)); + services.AddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); + services.AddScoped(typeof(IUAuthTokenService<>), typeof(UAuthTokenService<>)); + // ----------------------------- // SESSION / TOKEN ISSUERS // ----------------------------- diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs index 49293e4a..f2ffcece 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class BearerSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs index b69111b3..dd5c6f60 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class CompositeSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs index cb33ac7d..c7d533e3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class CookieSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs new file mode 100644 index 00000000..fbfdcfde --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class DefaultUserAuthenticator : IUserAuthenticator + { + private readonly IUAuthUserStore _userStore; + private readonly IUAuthPasswordHasher _passwordHasher; + + public DefaultUserAuthenticator(IUAuthUserStore userStore, IUAuthPasswordHasher passwordHasher) + { + _userStore = userStore; + _passwordHasher = passwordHasher; + } + + public async Task> AuthenticateAsync( + string? tenantId, + string username, + string secret, + CancellationToken cancellationToken = default) + { + var user = await _userStore.FindByUsernameAsync( + tenantId, + username, + cancellationToken); + + if (user is null) + return UserAuthenticationResult.Fail(); + + if (!user.IsActive) + return UserAuthenticationResult.Fail(); + + if (!_passwordHasher.Verify(secret, user.PasswordHash)) + return UserAuthenticationResult.Fail(); + + return UserAuthenticationResult.Success(user.Id, user.Claims, user.RequiresMfa); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs index aad25f48..ca6521e6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class HeaderSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs index bddb5067..46c063b6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public interface ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs new file mode 100644 index 00000000..b388f556 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal interface ISessionOrchestrator + { + Task> CreateLoginSessionAsync(AuthenticatedSessionContext context); + + Task> RotateSessionAsync(SessionRotationContext context); + + Task> ValidateSessionAsync(SessionValidationContext context); + + Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId); + + Task>> GetChainsAsync(string? tenantId, TUserId userId); + + Task ResolveChainIdAsync(string? tenantId,AuthSessionId sessionId); + + Task>> GetSessionsAsync(string? tenantId, ChainId chainId); + + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); + + Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId,DateTime at); + + Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs new file mode 100644 index 00000000..803bb4b9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + /// + /// Issues access and refresh tokens according to the active auth mode. + /// Does not perform persistence or validation. + /// + public interface ITokenIssuer + { + Task IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs similarity index 71% rename from src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs index 05a0e5ad..87de8da9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Users +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public interface IUserAccessor { diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs index 3019d8cf..237a9b18 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class QuerySessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs new file mode 100644 index 00000000..5a3b0642 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class SystemClock : IClock + { + public DateTime UtcNow => DateTime.UtcNow; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs new file mode 100644 index 00000000..67e8ea61 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs @@ -0,0 +1,13 @@ +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed record TokenIssuanceContext + { + public string UserId { get; init; } = default!; + public string? TenantId { get; init; } + public IReadOnlyCollection Claims { get; init; } = Array.Empty(); + public string? SessionId { get; init; } + public DateTime IssuedAt { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs new file mode 100644 index 00000000..15ef29a4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs @@ -0,0 +1,49 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class DefaultDeviceResolver : IDeviceResolver + { + public DeviceInfo Resolve(HttpContext context) + { + var request = context.Request; + + return new DeviceInfo + { + DeviceId = ResolveDeviceId(context), + Platform = ResolvePlatform(request), + OperatingSystem = null, // optional UA parsing later + Browser = request.Headers.UserAgent.ToString(), + IpAddress = context.Connection.RemoteIpAddress?.ToString(), + UserAgent = request.Headers.UserAgent.ToString(), + IsTrusted = null + }; + } + + private static string ResolveDeviceId(HttpContext context) + { + if (context.Request.Headers.TryGetValue("X-Device-Id", out var header)) + return header.ToString(); + + if (context.Request.Cookies.TryGetValue("ua_device", out var cookie)) + return cookie; + + return "unknown"; + } + + private static string? ResolvePlatform(HttpRequest request) + { + var ua = request.Headers.UserAgent.ToString().ToLowerInvariant(); + + 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 os")) return "macos"; + if (ua.Contains("linux")) return "linux"; + + return "web"; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs index 75afe790..9219aaa3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs @@ -2,7 +2,7 @@ using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class UAuthSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs new file mode 100644 index 00000000..7bcd741e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs @@ -0,0 +1,337 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Issuers; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + /// + /// Default UltimateAuth session store implementation. + /// Handles session, chain, and root orchestration on top of a kernel store. + /// + public sealed class UAuthSessionOrchestrator : ISessionOrchestrator + { + private readonly ISessionStoreFactory _factory; + private readonly UAuthSessionIssuer _sessionIssuer; + private readonly UAuthServerOptions _serverOptions; + + public UAuthSessionOrchestrator(ISessionStoreFactory factory, UAuthSessionIssuer sessionIssuer, UAuthServerOptions serverOptions) + { + _factory = factory; + _sessionIssuer = sessionIssuer; + _serverOptions = serverOptions; + } + + public async Task> CreateLoginSessionAsync(AuthenticatedSessionContext context) + { + var kernel = _factory.Create(context.TenantId); + + var root = await kernel.GetSessionRootAsync( + context.TenantId, + context.UserId); + + if (root is null) + { + root = UAuthSessionRoot.Create( + context.TenantId, + context.UserId, + context.Now); + } + else if (root.IsRevoked) + { + throw new UAuthSessionRootRevokedException(context.UserId!); + } + + ISessionChain chain; + + if (context.ChainId is not null) + { + chain = await kernel.GetChainAsync( + context.TenantId, + context.ChainId.Value) + ?? throw new UAuthSessionChainNotFoundException( + context.ChainId.Value); + + if (chain.IsRevoked) + throw new UAuthSessionChainRevokedException( + chain.ChainId); + } + else + { + chain = UAuthSessionChain.Create( + ChainId.New(), + context.TenantId, + context.UserId, + root.SecurityVersion, + context.Claims); + } + + var issuedSession = await _sessionIssuer.IssueAsync( + context, + chain); + + await kernel.ExecuteAsync(async () => + { + await kernel.SaveSessionAsync( + context.TenantId, + issuedSession.Session); + + var updatedChain = chain.AttachSession( + issuedSession.Session.SessionId); + + await kernel.SaveChainAsync( + context.TenantId, + updatedChain); + + await kernel.SaveSessionRootAsync( + context.TenantId, + root); + }); + + return issuedSession; + } + + public async Task> RotateSessionAsync(SessionRotationContext context) + { + var kernel = _factory.Create(context.TenantId); + + var currentSession = await kernel.GetSessionAsync( + context.TenantId, + context.CurrentSessionId); + + if (currentSession is null) + throw new UAuthSessionNotFoundException(context.CurrentSessionId); + + if (currentSession.IsRevoked) + throw new UAuthSessionRevokedException(context.CurrentSessionId); + + var state = currentSession.GetState(context.Now); + if (state != SessionState.Active) + throw new UAuthSessionInvalidStateException( + context.CurrentSessionId, state); + + var chainId = await kernel.GetChainIdBySessionAsync( + context.TenantId, + context.CurrentSessionId); + + if (chainId is null) + throw new UAuthSessionChainLinkMissingException(context.CurrentSessionId); + + var chain = await kernel.GetChainAsync( + context.TenantId, + chainId.Value); + + if (chain is null || chain.IsRevoked) + throw new UAuthSessionChainRevokedException(chainId.Value); + + var root = await kernel.GetSessionRootAsync( + context.TenantId, + currentSession.UserId); + + if (root is null || root.IsRevoked) + throw new UAuthSessionRootRevokedException( + currentSession.UserId!); + + if (currentSession.SecurityVersionAtCreation != root.SecurityVersion) + throw new UAuthSessionSecurityMismatchException( + context.CurrentSessionId, + root.SecurityVersion); + + var issueContext = new AuthenticatedSessionContext + { + TenantId = root.TenantId, + UserId = currentSession.UserId, + Now = context.Now, + DeviceInfo = context.Device, + Claims = context.Claims + }; + + var issuedSession = await _sessionIssuer.IssueAsync( + issueContext, + chain); + + await kernel.ExecuteAsync(async () => + { + await kernel.RevokeSessionAsync( + context.TenantId, + context.CurrentSessionId, + context.Now); + + await kernel.SaveSessionAsync( + context.TenantId, + issuedSession.Session); + + var rotatedChain = chain.RotateSession( + issuedSession.Session.SessionId); + + await kernel.SaveChainAsync( + context.TenantId, + rotatedChain); + + await kernel.SaveSessionRootAsync( + context.TenantId, + root); + }); + + return issuedSession; + } + + public async Task> ValidateSessionAsync( + SessionValidationContext context) + { + var kernel = _factory.Create(context.TenantId); + + // 1️⃣ Load session + var session = await kernel.GetSessionAsync( + context.TenantId, + context.SessionId); + + if (session is null) + return SessionValidationResult.Invalid(SessionState.NotFound); + + var state = session.GetState(context.Now); + + if (state != SessionState.Active) + return SessionValidationResult.Invalid(state); + + // 2️⃣ Resolve chain + var chainId = await kernel.GetChainIdBySessionAsync( + context.TenantId, + context.SessionId); + + if (chainId is null) + return SessionValidationResult.Invalid(SessionState.Invalid); + + var chain = await kernel.GetChainAsync( + context.TenantId, + chainId.Value); + + if (chain is null || chain.IsRevoked) + return SessionValidationResult.Invalid(SessionState.Revoked); + + // 3️⃣ Resolve root + var root = await kernel.GetSessionRootAsync( + context.TenantId, + session.UserId); + + if (root is null || root.IsRevoked) + return SessionValidationResult.Invalid(SessionState.Revoked); + + // 4️⃣ Security version check + if (session.SecurityVersionAtCreation != root.SecurityVersion) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch); + + // 5️⃣ Device check + if (!session.Device.Matches(context.Device)) + return SessionValidationResult.Invalid(SessionState.DeviceMismatch); + + // 6️⃣ Touch session (best-effort) + if (session.ShouldUpdateLastSeen(context.Now)) + { + var updated = session.Touch(context.Now); + await kernel.SaveSessionAsync(context.TenantId, updated); + session = updated; + } + + // 7️⃣ Success + return SessionValidationResult.Active( + session, + chain, + root); + } + + public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId) + { + var kernel = _factory.Create(tenantId); + return kernel.GetSessionAsync(tenantId, sessionId); + } + + public Task>> GetSessionsAsync(string? tenantId, ChainId chainId) + { + var kernel = _factory.Create(tenantId); + return kernel.GetSessionsByChainAsync(tenantId, chainId); + } + + public Task>> GetChainsAsync(string? tenantId, TUserId userId) + { + var kernel = _factory.Create(tenantId); + return kernel.GetChainsByUserAsync(tenantId, userId); + } + + public async Task ResolveChainIdAsync( + string? tenantId, + AuthSessionId sessionId) + { + var kernel = _factory.Create(tenantId); + return await kernel.GetChainIdBySessionAsync(tenantId, sessionId); + } + + public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at) + { + var kernel = _factory.Create(tenantId); + await kernel.RevokeSessionAsync(tenantId, sessionId, at); + } + + public async Task RevokeAllSessionsAsync( + string? tenantId, + TUserId userId, + DateTime at) + { + var kernel = _factory.Create(tenantId); + await kernel.RevokeSessionRootAsync(tenantId, userId, at); + } + + public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at) + { + var kernel = _factory.Create(tenantId); + await kernel.RevokeChainAsync(tenantId, chainId, at); + } + + public async Task RevokeAllChainsAsync( + string? tenantId, + TUserId userId, + ChainId? exceptChainId, + DateTime at) + { + var kernel = _factory.Create(tenantId); + + var chains = await kernel.GetChainsByUserAsync(tenantId, userId); + + await kernel.ExecuteAsync(async () => + { + foreach (var chain in chains) + { + if (exceptChainId.HasValue && + chain.ChainId.Equals(exceptChainId.Value)) + { + continue; + } + + if (!chain.IsRevoked) + { + await kernel.RevokeChainAsync( + tenantId, + chain.ChainId, + at); + } + } + }); + } + + public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at) + { + var kernel = _factory.Create(tenantId); + + await kernel.ExecuteAsync(async () => + { + await kernel.RevokeSessionRootAsync( + tenantId, + userId, + at); + }); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs index 0769479e..d9c85bb4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs @@ -1,19 +1,19 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Users +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class UAuthUserAccessor : IUserAccessor { private readonly ISessionStore _sessionStore; - private readonly IUserStore _userStore; + private readonly IUAuthUserStore _userStore; public UAuthUserAccessor( ISessionStore sessionStore, - IUserStore userStore) + IUAuthUserStore userStore) { _sessionStore = sessionStore; _userStore = userStore; @@ -43,7 +43,7 @@ public async Task ResolveAsync(HttpContext context) } // 👤 Load user - var user = await _userStore.FindByIdAsync(session.UserId); + var user = await _userStore.FindByIdAsync(sessionCtx.TenantId, session.UserId); if (user is null) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs new file mode 100644 index 00000000..d42d35b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs @@ -0,0 +1,12 @@ +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/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/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs index 3a224faa..201d6b57 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Domain.Session; using CodeBeam.UltimateAuth.Server.Options; @@ -23,10 +23,11 @@ public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, IOptions> IssueAsync( - SessionIssueContext context, - UAuthSessionChain chain, - CancellationToken cancellationToken = default) + AuthenticatedSessionContext context, + ISessionChain chain, + CancellationToken cancellationToken = default) { if (_options.Mode == UAuthMode.PureJwt) { @@ -43,9 +44,7 @@ public Task> IssueAsync( context.Now.Add(_options.Session.MaxLifetime.Value); if (absoluteExpiry < expiresAt) - { expiresAt = absoluteExpiry; - } } var session = UAuthSession.Create( @@ -54,9 +53,9 @@ public Task> IssueAsync( userId: context.UserId, now: context.Now, expiresAt: expiresAt, - securityVersion: context.SecurityVersion, - device: context.Device, - metadata: SessionMetadata.Empty + claims: context.Claims, + device: context.DeviceInfo, + metadata: context.Metadata ); return Task.FromResult(new IssuedSession diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs index 46d6e3fb..e965827d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs @@ -1,7 +1,8 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; using System.Security.Claims; @@ -28,7 +29,7 @@ public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerato _options = options.Value; } - public Task IssueAccessTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default) + public Task IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var expires = now.Add(_options.Tokens.AccessTokenLifetime); @@ -50,10 +51,10 @@ UAuthMode.SemiHybrid or }; } - public Task IssueRefreshTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default) + public Task IssueRefreshTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) { if (_options.Mode == UAuthMode.PureOpaque) - return Task.FromResult(null); + return Task.FromResult(null); var now = DateTimeOffset.UtcNow; var expires = now.Add(_options.Tokens.RefreshTokenLifetime); @@ -61,7 +62,7 @@ UAuthMode.SemiHybrid or string token = _opaqueGenerator.Generate(); string hash = _tokenHasher.Hash(token); - return Task.FromResult(new IssuedRefreshToken + return Task.FromResult(new RefreshToken { Token = token, TokenHash = hash, @@ -69,20 +70,20 @@ UAuthMode.SemiHybrid or }); } - private IssuedAccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) + private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) { string token = _opaqueGenerator.Generate(); - return new IssuedAccessToken + return new AccessToken { Token = token, - TokenType = "opaque", + Type = TokenType.Opaque, ExpiresAt = expires, SessionId = sessionId }; } - private IssuedAccessToken IssueJwtAccessToken(TokenIssueContext context, DateTimeOffset expires) + private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, DateTimeOffset expires) { var claims = new List { @@ -114,10 +115,10 @@ private IssuedAccessToken IssueJwtAccessToken(TokenIssueContext context, DateTim string jwt = _jwtGenerator.CreateToken(descriptor); - return new IssuedAccessToken + return new AccessToken { Token = jwt, - TokenType = "jwt", + Type = TokenType.Jwt, ExpiresAt = expires, SessionId = context.SessionId }; diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index 3ec6a3b4..2f5380e1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -1,5 +1,6 @@ -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Sessions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Middlewares diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs index aa87a464..079fbc81 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Server.Users; +using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Middlewares diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs deleted file mode 100644 index 88b3c741..00000000 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.MultiTenancy -{ - public sealed class DomainTenantAdapter : ITenantResolver - { - private readonly ITenantIdResolver _coreResolver; - private readonly UAuthMultiTenantOptions _options; - - public DomainTenantAdapter( - HostTenantResolver coreResolver, - UAuthMultiTenantOptions options) - { - _coreResolver = coreResolver; - _options = options; - } - - public async Task ResolveAsync(HttpContext ctx) - { - if (!_options.Enabled) - return UAuthTenantContext.NotResolved(); - - var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); - var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); - - if (tenantId is null) - return UAuthTenantContext.NotResolved(); - - return UAuthTenantContextFactory.Create(tenantId, _options); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs deleted file mode 100644 index 8250cdb8..00000000 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.MultiTenancy -{ - public sealed class HeaderTenantAdapter : ITenantResolver - { - private readonly ITenantIdResolver _coreResolver; - private readonly UAuthMultiTenantOptions _options; - - public HeaderTenantAdapter( - HeaderTenantResolver coreResolver, - UAuthMultiTenantOptions options) - { - _coreResolver = coreResolver; - _options = options; - } - - public async Task ResolveAsync(HttpContext ctx) - { - if (!_options.Enabled) - return UAuthTenantContext.NotResolved(); - - var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); - - var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); - if (tenantId is null) - return UAuthTenantContext.NotResolved(); - - return UAuthTenantContextFactory.Create(tenantId, _options); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs deleted file mode 100644 index e69719ab..00000000 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.MultiTenancy; -using Microsoft.AspNetCore.Http; - -public sealed class RouteTenantAdapter : ITenantResolver -{ - private readonly ITenantIdResolver _coreResolver; - private readonly UAuthMultiTenantOptions _options; - - public RouteTenantAdapter( - PathTenantResolver coreResolver, - UAuthMultiTenantOptions options) - { - _coreResolver = coreResolver; - _options = options; - } - - public async Task ResolveAsync(HttpContext ctx) - { - if (!_options.Enabled) - return UAuthTenantContext.NotResolved(); - - var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); - var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); - - if (tenantId is null) - return UAuthTenantContext.NotResolved(); - - return UAuthTenantContextFactory.Create(tenantId, _options); - } -} 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/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs new file mode 100644 index 00000000..084e3b00 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -0,0 +1,149 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthFlowService : IUAuthFlowService + { + private readonly IUAuthUserService _users; + private readonly IUAuthSessionService _sessions; + private readonly IUAuthTokenService _tokens; + + public UAuthFlowService( + IUAuthUserService users, + IUAuthSessionService sessions, + IUAuthTokenService tokens) + { + _users = users; + _sessions = sessions; + _tokens = tokens; + } + + public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task ConsumePkceAsync(PkceConsumeRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task CreatePkceChallengeAsync(PkceCreateRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) + { + var now = request.At ?? DateTime.UtcNow; + var device = request.DeviceInfo ?? DeviceInfo.Unknown; + + var authResult = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); + + if (!authResult.Succeeded) + { + return LoginResult.Failed(); + } + + var sessionResult = await _sessions.IssueSessionAfterAuthenticationAsync(request.TenantId, + new AuthenticatedSessionContext + { + TenantId = request.TenantId, + UserId = authResult.UserId!, + Now = now, + DeviceInfo = device, + Claims = authResult.Claims, + ChainId = request.ChainId + }); + + AuthTokens? tokens = null; + + if (request.RequestTokens) + { + tokens = await _tokens.CreateTokensAsync( + new TokenIssueContext + { + TenantId = request.TenantId, + Session = sessionResult.Session, + Now = now + }, + ct); + } + + return LoginResult.Success(sessionResult.Session.SessionId, tokens); + } + + public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + { + var at = request.At ?? DateTime.UtcNow; + await _sessions.RevokeSessionAsync(request.TenantId, request.SessionId, at); + } + + public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) + { + var at = request.At ?? DateTime.UtcNow; + + if (request.CurrentSessionId is null) + throw new InvalidOperationException( + "CurrentSessionId must be provided for logout-all operation."); + + var currentSessionId = request.CurrentSessionId.Value; + + var validation = await _sessions.ValidateSessionAsync( + request.TenantId, + currentSessionId, + at); + + if (validation.IsValid || + validation.Session is null) + throw new InvalidOperationException("Current session is not valid."); + + var userId = validation.Session.UserId; + + ChainId? currentChainId = null; + + if (request.ExceptCurrent) + { + if (request.CurrentSessionId is null) + throw new InvalidOperationException("CurrentSessionId must be provided when ExceptCurrent is true."); + + currentChainId = await _sessions.ResolveChainIdAsync( + request.TenantId, + currentSessionId); + + if (currentChainId is null) + throw new InvalidOperationException("Current session chain could not be resolved."); + } + + await _sessions.RevokeAllChainsAsync(request.TenantId, userId, exceptChainId: currentChainId, at); + } + + public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs new file mode 100644 index 00000000..43cdf356 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs @@ -0,0 +1,125 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthSessionService : IUAuthSessionService + { + private readonly ISessionOrchestrator _orchestrator; + + public UAuthSessionService(ISessionOrchestrator orchestrator) + { + _orchestrator = orchestrator; + } + + public Task> ValidateSessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTime now) + { + var context = new SessionValidationContext() + { + TenantId = tenantId, + Now = now + }; + + return _orchestrator.ValidateSessionAsync(context); + } + + public Task>> GetChainsAsync( + string? tenantId, + TUserId userId) + => _orchestrator.GetChainsAsync( + tenantId, + userId); + + public Task>> GetSessionsAsync( + string? tenantId, + ChainId chainId) + => _orchestrator.GetSessionsAsync( + tenantId, + chainId); + + public Task?> GetSessionAsync( + string? tenantId, + AuthSessionId sessionId) + => _orchestrator.GetSessionAsync( + tenantId, + sessionId); + + public Task RevokeSessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTime at) + => _orchestrator.RevokeSessionAsync( + tenantId, + sessionId, + at); + + public Task ResolveChainIdAsync( + string? tenantId, + AuthSessionId sessionId) + => _orchestrator.ResolveChainIdAsync(tenantId, sessionId); + + public Task RevokeAllChainsAsync( + string? tenantId, + TUserId userId, + ChainId? exceptChainId, + DateTime at) + => _orchestrator.RevokeAllChainsAsync( + tenantId, + userId, + exceptChainId, + at); + + public Task RevokeChainAsync( + string? tenantId, + ChainId chainId, + DateTime at) + => _orchestrator.RevokeChainAsync( + tenantId, + chainId, + at); + + public Task RevokeRootAsync( + string? tenantId, + TUserId userId, + DateTime at) + => _orchestrator.RevokeRootAsync( + tenantId, + userId, + at); + + public Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) + { + // TODO: Implement this method + throw new NotImplementedException(); + } + + public Task> IssueSessionAfterAuthenticationAsync( + string? tenantId, + AuthenticatedSessionContext context, + CancellationToken cancellationToken = default) + { + if (context.UserId is null) + throw new InvalidOperationException( + "Authenticated session context requires a valid user id."); + + // Authenticated → IssueContext map + var issueContext = new AuthenticatedSessionContext + { + TenantId = tenantId, + UserId = context.UserId, + Now = context.Now, + DeviceInfo = context.DeviceInfo, + Claims = context.Claims, + ChainId = context.ChainId + }; + + return _orchestrator.CreateLoginSessionAsync(issueContext); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs new file mode 100644 index 00000000..6d35fbf5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs @@ -0,0 +1,62 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthTokenService : IUAuthTokenService + { + private readonly ITokenIssuer _issuer; + private readonly ITokenValidator _validator; + private readonly IUserIdConverter _userIdConverter; + + public UAuthTokenService(ITokenIssuer issuer, ITokenValidator validator, IUserIdConverterResolver converterResolver) + { + _issuer = issuer; + _validator = validator; + _userIdConverter = converterResolver.GetConverter(); + } + + public async Task CreateTokensAsync( + TokenIssueContext context, + CancellationToken ct = default) + { + var issuerCtx = ToIssuerContext(context); + + var access = await _issuer.IssueAccessTokenAsync(issuerCtx, ct); + var refresh = await _issuer.IssueRefreshTokenAsync(issuerCtx, ct); + + return new AuthTokens + { + AccessToken = access, + RefreshToken = refresh + }; + } + + public async Task RefreshAsync( + TokenRefreshContext context, + CancellationToken ct = default) + { + throw new NotImplementedException("Refresh flow will be implemented after refresh-token store & validation."); + } + + public async Task> ValidateAsync( + string token, + TokenType type, + CancellationToken ct = default) + => await _validator.ValidateAsync(token, type, ct); + + private TokenIssuanceContext ToIssuerContext(TokenIssueContext src) + { + return new TokenIssuanceContext + { + UserId = _userIdConverter.ToString(src.Session.UserId), + TenantId = src.TenantId, + SessionId = src.Session.SessionId, + Claims = src.Session.Claims.AsClaims() + }; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs new file mode 100644 index 00000000..d48cba0f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs @@ -0,0 +1,165 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthTokenValidator : ITokenValidator + { + private readonly IOpaqueTokenStore _opaqueStore; + private readonly JsonWebTokenHandler _jwtHandler; + private readonly TokenValidationParameters _jwtParameters; + private readonly IUserIdConverterResolver _converters; + private readonly UAuthServerOptions _options; + private readonly ITokenHasher _tokenHasher; + + public UAuthTokenValidator( + IOpaqueTokenStore opaqueStore, + TokenValidationParameters jwtParameters, + IUserIdConverterResolver converters, + IOptions options, + ITokenHasher tokenHasher) + { + _opaqueStore = opaqueStore; + _jwtHandler = new JsonWebTokenHandler(); + _jwtParameters = jwtParameters; + _converters = converters; + _options = options.Value; + _tokenHasher = tokenHasher; + } + + public async Task> ValidateAsync( + string token, + TokenType type, + CancellationToken ct = default) + { + return type switch + { + TokenType.Jwt => await ValidateJwt(token), + TokenType.Opaque => await ValidateOpaqueAsync(token, ct), + _ => TokenValidationResult.Invalid(TokenType.Unknown, TokenInvalidReason.Unknown) + }; + } + + // ---------------- JWT ---------------- + + private async Task> ValidateJwt(string token) + { + 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 (!string.IsNullOrWhiteSpace(sid)) + { + sessionId = new AuthSessionId(sid); + } + + return TokenValidationResult.Valid( + type: TokenType.Jwt, + tenantId: tenantId, + userId, + sessionId: sessionId, + claims: claims, + expiresAt: jwt.ValidTo); + } + + + // ---------------- OPAQUE ---------------- + + private async Task> ValidateOpaqueAsync(string token, CancellationToken ct) + { + var hash = _tokenHasher.Hash(token); + + var record = await _opaqueStore.FindByHashAsync(hash, ct); + if (record is null) + { + return TokenValidationResult.Invalid( + TokenType.Opaque, + TokenInvalidReason.Invalid); + } + + var now = DateTimeOffset.UtcNow; + if (record.ExpiresAt <= now) + { + return TokenValidationResult.Invalid( + TokenType.Opaque, + TokenInvalidReason.Expired); + } + + if (record.IsRevoked) + { + return TokenValidationResult.Invalid( + TokenType.Opaque, + TokenInvalidReason.Revoked); + } + + var converter = _converters.GetConverter(); + + TUserId userId; + try + { + userId = converter.FromString(record.UserId); + } + catch + { + return TokenValidationResult.Invalid( + TokenType.Opaque, + TokenInvalidReason.Invalid); + } + + return TokenValidationResult.Valid( + TokenType.Opaque, + record.TenantId, + userId, + record.SessionId, + record.Claims, + record.ExpiresAt.UtcDateTime); + } + + 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/UAuthUserService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs new file mode 100644 index 00000000..bb02e0b2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs @@ -0,0 +1,89 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Users; + +internal sealed class UAuthUserService : IUAuthUserService +{ + private readonly IUAuthUserStore _userStore; + private readonly IUAuthPasswordHasher _passwordHasher; + private readonly IUserIdFactory _userIdFactory; + private readonly IUserAuthenticator _authenticator; + + public UAuthUserService( + IUAuthUserStore userStore, + IUAuthPasswordHasher passwordHasher, + IUserIdFactory userIdFactory, + IUserAuthenticator authenticator) + { + _userStore = userStore; + _passwordHasher = passwordHasher; + _userIdFactory = userIdFactory; + _authenticator = authenticator; + } + + public async Task RegisterAsync( + RegisterUserRequest request, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(request.Identifier)) + throw new ArgumentException("Username is required."); + + if (string.IsNullOrWhiteSpace(request.Password)) + throw new ArgumentException("Password is required."); + + if (await _userStore.ExistsByUsernameAsync(request.Identifier, ct)) + throw new InvalidOperationException("User already exists."); + + var hash = _passwordHasher.Hash(request.Password); + + var userId = _userIdFactory.Create(); + + await _userStore.CreateAsync( + new UserRecord + { + Id = userId, + Username = request.Identifier, + PasswordHash = hash, + CreatedAt = DateTime.UtcNow + }, + ct); + + return userId; + } + + public async Task ValidateCredentialsAsync( + ValidateCredentialsRequest request, + CancellationToken ct = default) + { + var user = await _userStore.FindByUsernameAsync(request.TenantId, request.Identifier, ct); + if (user is null) + return false; + + return _passwordHasher.Verify( + request.Password, + user.PasswordHash); + } + + public async Task DeleteAsync( + TUserId userId, + CancellationToken ct = default) + { + await _userStore.DeleteAsync(userId, ct); + } + + public async Task> AuthenticateAsync( + string? tenantId, + string identifier, + string secret, + CancellationToken cancellationToken = default) + { + return await _authenticator.AuthenticateAsync( + tenantId, + identifier, + secret, + cancellationToken); + } +} + diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs deleted file mode 100644 index 358b8fd9..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contexts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Sessions -{ - /// - /// Orchestrates session, chain, and root lifecycles - /// according to UltimateAuth security rules. - /// - public interface ISessionOrchestrator - { - /// - /// Creates a new login session (initial authentication). - /// - Task> CreateLoginSessionAsync( - SessionIssueContext context); - - /// - /// Revokes a single session. - /// - Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTime at); - - /// - /// Revokes all sessions of a user (global logout). - /// - Task RevokeAllSessionsAsync( - string? tenantId, - TUserId userId, - DateTime at); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs deleted file mode 100644 index d16e03f3..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs +++ /dev/null @@ -1,235 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contexts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Issuers; -using CodeBeam.UltimateAuth.Server.Options; - -namespace CodeBeam.UltimateAuth.Server.Sessions -{ - /// - /// Default UltimateAuth session store implementation. - /// Handles session, chain, and root orchestration on top of a kernel store. - /// - public sealed class UAuthSessionOrchestrator : ISessionOrchestrator - { - private readonly ISessionStoreFactory _factory; - private readonly UAuthSessionIssuer _sessionIssuer; - private readonly UAuthServerOptions _serverOptions; - - public UAuthSessionOrchestrator(ISessionStoreFactory factory, UAuthSessionIssuer sessionIssuer, UAuthServerOptions serverOptions) - { - _factory = factory; - _sessionIssuer = sessionIssuer; - _serverOptions = serverOptions; - } - - public async Task> CreateLoginSessionAsync(SessionIssueContext context) - { - var kernel = _factory.Create(context.TenantId); - - // 1️⃣ Load or create root - var root = await kernel.GetSessionRootAsync( - context.TenantId, - context.UserId); - - if (root is null) - { - root = UAuthSessionRoot.Create( - context.TenantId, - context.UserId, - context.Now); - } - else if (root.IsRevoked) - { - throw new InvalidOperationException( - "User session root is revoked."); - } - - // 2️⃣ Load or create chain (interface → concrete) - ISessionChain? loadedChain = null; - - if (context.ChainId is not null) - { - loadedChain = await kernel.GetChainAsync( - context.TenantId, - context.ChainId.Value); - } - - if (loadedChain is not null && loadedChain.IsRevoked) - { - throw new InvalidOperationException( - "Session chain is revoked."); - } - - UAuthSessionChain chain; - - if (loadedChain is null) - { - chain = UAuthSessionChain.Create( - ChainId.New(), - context.TenantId, - context.UserId, - root.SecurityVersion, - context.ClaimsSnapshot); - } - else if (loadedChain is UAuthSessionChain concreteChain) - { - chain = concreteChain; - } - else - { - throw new InvalidOperationException( - "Unsupported ISessionChain implementation. " + - "UltimateAuth requires SessionChain."); - } - - // TODO: Add cancellation token support - var issuedSession = await _sessionIssuer.IssueAsync( - context, - chain); - - // 4️⃣ Persist session - await kernel.SaveSessionAsync( - context.TenantId, - issuedSession.Session); - - // 5️⃣ Update & persist chain - var updatedChain = chain.ActivateSession( - issuedSession.Session.SessionId); - - await kernel.SaveChainAsync( - context.TenantId, - updatedChain); - - // 6️⃣ Persist root (idempotent) - await kernel.SaveSessionRootAsync( - context.TenantId, - root); - - return issuedSession; - } - - public async Task> RotateSessionAsync(string? tenantId, AuthSessionId currentSessionId, SessionIssueContext context) - { - if (_serverOptions.Mode == UAuthMode.PureJwt) - throw new InvalidOperationException( - "Session rotation is not available in PureJwt mode."); - - var kernel = _factory.Create(tenantId); - - // 1️⃣ Load current session - var currentSession = await kernel.GetSessionAsync( - tenantId, - currentSessionId); - - if (currentSession is null) - throw new InvalidOperationException("Session not found."); - - if (currentSession.IsRevoked) - throw new InvalidOperationException("Session is revoked."); - - if (currentSession.GetState(context.Now) != SessionState.Active) - throw new InvalidOperationException("Session is not active."); - - // 2️⃣ Load chain id - var chainId = await kernel.GetChainIdBySessionAsync( - tenantId, - currentSessionId); - - if (chainId is null) - throw new InvalidOperationException("Session chain not found."); - - // 3️⃣ Load chain - var loadedChain = await kernel.GetChainAsync( - tenantId, - chainId.Value); - - if (loadedChain is null || loadedChain.IsRevoked) - throw new InvalidOperationException("Session chain is revoked."); - - if (loadedChain is not UAuthSessionChain chain) - throw new InvalidOperationException( - "Unsupported ISessionChain implementation."); - - // 4️⃣ Load root - var root = await kernel.GetSessionRootAsync( - tenantId, - context.UserId); - - if (root is null || root.IsRevoked) - throw new InvalidOperationException("Session root is revoked."); - - // 5️⃣ Security version check - if (currentSession.SecurityVersionAtCreation != root.SecurityVersion) - throw new InvalidOperationException( - "Session security version mismatch."); - - // TODO: Add cancellation token support - var issuedSession = await _sessionIssuer.IssueAsync( - context, - chain); - - // 7️⃣ Persist new session - await kernel.SaveSessionAsync( - tenantId, - issuedSession.Session); - - // 8️⃣ Revoke old session - await kernel.RevokeSessionAsync( - tenantId, - currentSessionId, - context.Now); - - // 9️⃣ Activate new session in chain - var updatedChain = chain.ActivateSession( - issuedSession.Session.SessionId); - - await kernel.SaveChainAsync( - tenantId, - updatedChain); - - // 🔟 Root persistence (idempotent) - await kernel.SaveSessionRootAsync( - tenantId, - root); - - return issuedSession; - } - - public Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId) - { - var kernel = _factory.Create(tenantId); - return kernel.GetSessionAsync(tenantId, sessionId); - } - - public async Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTime at) - { - var kernel = _factory.Create(tenantId); - await kernel.RevokeSessionAsync(tenantId, sessionId, at); - } - - public async Task RevokeAllSessionsAsync( - string? tenantId, - TUserId userId, - DateTime at) - { - var kernel = _factory.Create(tenantId); - await kernel.RevokeSessionRootAsync(tenantId, userId, at); - } - - public async Task RevokeChainAsync( - string? tenantId, - ChainId chainId, - DateTime at) - { - var kernel = _factory.Create(tenantId); - await kernel.RevokeChainAsync(tenantId, chainId, at); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs b/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs new file mode 100644 index 00000000..ac2fc6f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CodeBeam.UltimateAuth.Server.Stores +{ + public sealed class AspNetIdentityUserStore // : IUAuthUserStore + { + //private readonly UserManager _users; + + //public AspNetIdentityUserStore(UserManager users) + //{ + // _users = users; + //} + + //public async Task?> FindByUsernameAsync( + // string? tenantId, + // string username, + // CancellationToken cancellationToken = default) + //{ + // var user = await _users.FindByNameAsync(username); + // if (user is null) + // return null; + + // var claims = await _users.GetClaimsAsync(user); + + // return new UAuthUserRecord + // { + // UserId = user.Id, + // Username = user.UserName!, + // PasswordHash = user.PasswordHash!, + // Claims = ClaimsSnapshot.From( + // claims.Select(c => (c.Type, c.Value)).ToArray()) + // }; + //} + } + +} From cdcb780783e31f25f2d167396daba43f02909944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 18 Dec 2025 20:59:33 +0300 Subject: [PATCH 11/50] Finalize Server Project With Minimum Features (Part 3) (#5) * Start to Authority Layer * Complete Basic Authority Layer * Finalize Server Project's Layers * Change Slnx --- UltimateAuth.slnx | 2 +- .../Abstractions/Authority/IAuthAuthority.cs | 10 + .../Authority/IAuthorityInvariant.cs | 9 + .../Authority/IAuthorityPolicy.cs | 10 + .../Abstractions/Infrastructure/IClock.cs | 2 +- .../Infrastructure/IRefreshTokenResolver.cs | 10 + .../Abstractions/Issuers/ISessionIssuer.cs | 15 +- .../Services/IUAuthSessionService.cs | 10 +- .../Abstractions/Stores/ISessionStore.cs | 6 +- .../Stores/ISessionStoreKernel.cs | 20 +- .../Abstractions/Stores/ITokenStore.cs | 11 +- .../Abstractions/Stores/ITokenStoreFactory.cs | 7 + .../Abstractions/Stores/ITokenStoreKernel.cs | 47 +++ .../Contracts/Authority/AuthContext.cs | 61 ++++ .../Contracts/Authority/AuthOperation.cs | 12 + .../Authority/AuthorizationDecision.cs | 10 + .../Authority/AuthorizationResult.cs | 29 ++ .../Contracts/Authority/DeviceContext.cs | 28 ++ .../Authority/SessionAccessContext.cs | 18 + .../Contracts/Login/LoginRequest.cs | 2 +- .../Contracts/Logout/LogoutAllRequest.cs | 2 +- .../Contracts/Logout/LogoutRequest.cs | 6 +- .../Session/AuthenticatedSessionContext.cs | 2 +- .../Session/ResolvedRefreshSession.cs | 38 ++ .../Contracts/Session/SessionRefreshResult.cs | 17 +- .../Session/SessionRotationContext.cs | 2 +- .../Session/SessionValidationContext.cs | 2 +- .../Token/RefreshTokenFailureReason.cs | 10 + .../Token/RefreshTokenValidationResult.cs | 31 ++ .../Contracts/Token/TokenIssueContext.cs | 2 +- .../Contracts/Token/TokenValidationResult.cs | 6 +- .../Contracts/Unit.cs | 7 + .../Domain/Session/ChainId.cs | 4 + .../Domain/Session/ISession.cs | 18 +- .../Domain/Session/ISessionChain.cs | 4 +- .../Domain/Session/ISessionRoot.cs | 8 +- .../Domain/Session/UAuthSession.cs | 44 ++- .../Domain/Session/UAuthSessionChain.cs | 6 +- .../Domain/Session/UAuthSessionRoot.cs | 30 +- .../Domain/Token/StoredRefreshToken.cs | 19 + .../Domain/Token/UAuthJwtTokenDescriptor.cs | 2 +- .../UAuthChallengeRequiredException.cs | 10 + .../Base/UAuthAuthorizationException.cs | 10 + .../Events/SessionCreatedContext.cs | 4 +- .../Events/SessionRefreshedContext.cs | 4 +- .../Events/SessionRevokedContext.cs | 4 +- .../Events/UserLoggedInContext.cs | 4 +- .../Events/UserLoggedOutContext.cs | 4 +- .../Authority/DefaultAuthAuthority.cs | 43 +++ .../Authority/DeviceTrustPolicy.cs | 32 ++ .../Authority/ExpiredSessionInvariant.cs | 27 ++ .../InvalidOrRevokedSessionInvariant.cs | 31 ++ .../Authority/UAuthModeOperationPolicy.cs | 39 ++ .../UAuthRefreshTokenResolver.cs | 83 +++++ .../Infrastructure/UserRecord.cs | 2 +- .../Contracts/LoginResponse.cs | 12 + .../Contracts/LogoutResponse.cs | 7 + .../Endpoints/DefaultLoginEndpointHandler.cs | 20 +- .../Endpoints/DefaultLogoutEndpointHandler.cs | 43 +++ .../Infrastructure/ISessionOrchestrator.cs | 30 -- .../Orchestrator/CreateLoginSessionCommand.cs | 13 + .../Orchestrator/ISessionCommand.cs | 10 + .../Orchestrator/ISessionOrchestrator.cs | 8 + .../Orchestrator/ISessionQueryService.cs | 18 + .../Orchestrator/RevokeAllChainsCommand.cs | 24 ++ .../Orchestrator/RevokeChainCommand.cs | 30 ++ .../Orchestrator/RevokeRootCommand.cs | 29 ++ .../Orchestrator/RevokeSessionCommand.cs | 15 + .../Orchestrator/RotateSessionCommand.cs | 13 + .../Orchestrator/UAuthSessionOrchestrator.cs | 44 +++ .../Orchestrator/UAuthSessionQueryService.cs | 101 ++++++ .../Infrastructure/SystemClock.cs | 2 +- .../Infrastructure/TokenIssuanceContext.cs | 2 +- .../UAuthSessionOrchestrator.cs | 337 ------------------ .../Issuers/UAuthSessionIssuer.cs | 275 ++++++++++++-- .../Services/UAuthFlowService.cs | 181 +++++++--- .../Services/UAuthSessionService.cs | 117 +++--- .../Services/UAuthUserService.cs | 2 +- .../IUAuthUserManagementService.cs | 0 .../Abstractions/IUAuthUserProfileService.cs | 0 .../CodeBeam.UltimateAuth.Server.Users.csproj | 0 .../Extensions/.gitkeep | 0 .../Middlewares/.gitkeep | 0 .../Options/.gitkeep | 0 .../Services/.gitkeep | 0 .../Users/Models/AdminUserFilter.cs | 0 .../Users/Models/ChangePasswordRequest.cs | 0 .../Users/Models/ConfigureMfaRequest.cs | 0 .../Users/Models/ResetPasswordRequest.cs | 0 .../Users/Models/UpdateProfileRequest.cs | 0 .../Users/Models/UserDto.cs | 4 +- .../Users/Models/UserProfileDto.cs | 2 +- 92 files changed, 1595 insertions(+), 620 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Abstractions/IUAuthUserManagementService.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Abstractions/IUAuthUserProfileService.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/CodeBeam.UltimateAuth.Server.Users.csproj (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Extensions/.gitkeep (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Middlewares/.gitkeep (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Options/.gitkeep (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Services/.gitkeep (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/AdminUserFilter.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/ChangePasswordRequest.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/ConfigureMfaRequest.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/ResetPasswordRequest.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/UpdateProfileRequest.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/UserDto.cs (75%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/UserProfileDto.cs (85%) diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 18a8f981..8f20ba91 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -3,7 +3,7 @@ - + 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..9da4a8fc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IAuthAuthority + { + AuthorizationResult Decide(AuthContext context); + } + +} 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..32ce7dce --- /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 +{ + public interface IAuthorityInvariant + { + AuthorizationResult 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..235ea3d5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IAuthorityPolicy + { + bool AppliesTo(AuthContext context); + AuthorizationResult Decide(AuthContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs index ce4905a8..a624091b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs @@ -6,6 +6,6 @@ /// public interface IClock { - DateTime UtcNow { get; } + DateTimeOffset UtcNow { get; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs new file mode 100644 index 00000000..00523451 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IRefreshTokenResolver + { + Task?> ResolveAsync(string? tenantId, string refreshToken, DateTimeOffset now, CancellationToken ct = default); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index 726bbfbf..4dcb392d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -3,11 +3,18 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions { - /// - /// Issues and manages authentication sessions. - /// public interface ISessionIssuer { - Task> IssueAsync(AuthenticatedSessionContext context, ISessionChain chain, CancellationToken cancellationToken = default); + Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + + Task> RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); + + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default); + + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default); + + Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); + + Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at,CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs index 25ad4f5b..73228f14 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs @@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// The type used to uniquely identify the user. public interface IUAuthSessionService { - Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at); Task>> GetChainsAsync(string? tenantId, TUserId userId); @@ -17,16 +17,16 @@ public interface IUAuthSessionService Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId); - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at); - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at); Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId); - Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTime at); + Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at); // Hard revoke - admin - Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at); + Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at); Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken cancellationToken = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 5b65e7d0..8126aca8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -37,7 +37,7 @@ Task RotateSessionAsync( Task RevokeSessionAsync( string? tenantId, AuthSessionId sessionId, - DateTime at); + DateTimeOffset at); /// /// Revokes all sessions for a specific user (all devices). @@ -45,7 +45,7 @@ Task RevokeSessionAsync( Task RevokeAllSessionsAsync( string? tenantId, TUserId userId, - DateTime at); + DateTimeOffset at); /// /// Revokes all sessions within a specific chain (single device). @@ -53,6 +53,6 @@ Task RevokeAllSessionsAsync( Task RevokeChainAsync( string? tenantId, ChainId chainId, - DateTime at); + DateTimeOffset at); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs index d398eb15..34e9afe4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs @@ -8,6 +8,12 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// public interface ISessionStoreKernel { + /// + /// Executes multiple store operations as a single atomic unit. + /// Implementations must ensure transactional consistency where supported. + /// + Task ExecuteAsync(Func action); + /// /// Retrieves a session by its identifier within the given tenant context. /// @@ -31,7 +37,7 @@ public interface ISessionStoreKernel /// The tenant identifier, or null. /// The session identifier. /// The UTC timestamp of revocation. - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at); /// /// Returns all sessions belonging to the specified chain, ordered according to store implementation rules. @@ -62,7 +68,7 @@ public interface ISessionStoreKernel /// The tenant identifier, or null. /// The chain to revoke. /// The UTC timestamp of revocation. - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at); /// /// Retrieves the active session identifier for the specified chain. @@ -112,14 +118,14 @@ public interface ISessionStoreKernel /// The tenant identifier, or null. /// The user whose root should be revoked. /// The UTC timestamp of revocation. - Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTime at); + Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTimeOffset at); /// /// Removes expired sessions from the store while leaving chains and session roots intact. Cleanup strategy is determined by the store implementation. /// /// The tenant identifier, or null. /// The current UTC timestamp. - Task DeleteExpiredSessionsAsync(string? tenantId, DateTime now); + Task DeleteExpiredSessionsAsync(string? tenantId, DateTimeOffset at); /// /// Retrieves the chain identifier associated with the specified session. @@ -128,11 +134,5 @@ public interface ISessionStoreKernel /// The session identifier. /// The chain identifier or null. Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId); - - /// - /// Executes multiple store operations as a single atomic unit. - /// Implementations must ensure transactional consistency where supported. - /// - Task ExecuteAsync(Func action); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs index ee116acc..d414189c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Core.Abstractions { @@ -22,11 +23,11 @@ Task StoreRefreshTokenAsync( /// Validates a provided refresh token against the stored hash. /// Returns true if valid and not expired or revoked. /// - Task ValidateRefreshTokenAsync( + Task> ValidateRefreshTokenAsync( string? tenantId, - TUserId userId, - AuthSessionId sessionId, - string providedRefreshToken); + string providedRefreshToken, + DateTimeOffset now); + /// /// Revokes the refresh token associated with the specified session. diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs new file mode 100644 index 00000000..4e3fdefe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface ITokenStoreFactory + { + ITokenStoreKernel Create(string? tenantId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs new file mode 100644 index 00000000..d708c842 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Low-level persistence abstraction for token-related data. + /// Handles refresh tokens and optional access token identifiers (jti). + /// + public interface ITokenStoreKernel + { + Task SaveRefreshTokenAsync( + string? tenantId, + StoredRefreshToken token); + + Task GetRefreshTokenAsync( + string? tenantId, + string tokenHash); + + Task RevokeRefreshTokenAsync( + string? tenantId, + string tokenHash, + DateTimeOffset at); + + Task RevokeAllRefreshTokensAsync( + string? tenantId, + string? userId, + DateTimeOffset at); + + Task DeleteExpiredRefreshTokensAsync( + string? tenantId, + DateTimeOffset now); + + Task StoreTokenIdAsync( + string? tenantId, + string jti, + DateTimeOffset expiresAt); + + Task IsTokenIdRevokedAsync( + string? tenantId, + string jti); + + Task RevokeTokenIdAsync( + string? tenantId, + string jti, + DateTimeOffset at); + } +} 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..bce952e5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -0,0 +1,61 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record AuthContext + { + public string? TenantId { get; init; } + + public AuthOperation Operation { get; init; } + + public UAuthMode Mode { get; init; } + + public SessionAccessContext? Session { get; init; } + + public DeviceContext Device { get; init; } + + public DateTimeOffset At { get; init; } + + private AuthContext() { } + + public static AuthContext System(string? tenantId, AuthOperation operation, DateTimeOffset at, UAuthMode mode = UAuthMode.Hybrid) + { + return new AuthContext + { + TenantId = tenantId, + Operation = operation, + Mode = mode, + At = at, + Session = null, + Device = null + }; + } + + public static AuthContext ForAuthenticatedUser(string? tenantId, AuthOperation operation, DateTimeOffset at, DeviceContext device, UAuthMode mode = UAuthMode.Hybrid) + { + return new AuthContext + { + TenantId = tenantId, + Operation = operation, + Mode = mode, + At = at, + Device = device, + Session = null + }; + } + + public static AuthContext ForSession(string? tenantId, AuthOperation operation, SessionAccessContext session, DateTimeOffset at, + DeviceContext device, UAuthMode mode = UAuthMode.Hybrid) + { + return new AuthContext + { + TenantId = tenantId, + Operation = operation, + Mode = mode, + At = at, + Session = session, + Device = device + }; + } + + + } +} 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..8f41f0d7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum AuthOperation + { + Login, + Access, + Refresh, + Revoke, + Logout, + System + } +} 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..80d71022 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum AuthorizationDecision + { + Allow, + Deny, + Challenge + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs new file mode 100644 index 00000000..09af255c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class AuthorizationResult + { + public AuthorizationDecision Decision { get; } + public string? Reason { get; } + + private AuthorizationResult(AuthorizationDecision decision, string? reason) + { + Decision = decision; + Reason = reason; + } + + public static AuthorizationResult Allow() + => new(AuthorizationDecision.Allow, null); + + public static AuthorizationResult Deny(string reason) + => new(AuthorizationDecision.Deny, reason); + + public static AuthorizationResult Challenge(string reason) + => new(AuthorizationDecision.Challenge, reason); + + // Developer happiness helpers + 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/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs new file mode 100644 index 00000000..8cbdeff9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record DeviceContext + { + public string DeviceId { get; init; } = default!; + + public bool IsKnownDevice { get; init; } + + public bool IsTrusted { get; init; } + + public string? Platform { get; init; } + + public string? UserAgent { get; init; } + + public static DeviceContext From(DeviceInfo info) + { + return new DeviceContext + { + DeviceId = info.DeviceId, + Platform = info.Platform, + UserAgent = info.UserAgent + }; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs new file mode 100644 index 00000000..4a32bf35 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record SessionAccessContext + { + public SessionState State { get; init; } + + public bool IsExpired { get; init; } + + public bool IsRevoked { get; init; } + + public string? ChainId { get; init; } + + public string? BoundDeviceId { get; init; } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index 97a75f0c..98f38954 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -7,7 +7,7 @@ public sealed record LoginRequest public string? TenantId { get; init; } public string Identifier { get; init; } = default!; // username, email etc. public string Secret { get; init; } = default!; // password - public DateTime? At { get; init; } + public DateTimeOffset? At { get; init; } public DeviceInfo DeviceInfo { get; init; } public IReadOnlyDictionary? Metadata { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs index 7bfa2da5..2aa6b6af 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs @@ -17,7 +17,7 @@ public sealed class LogoutAllRequest /// public bool ExceptCurrent { get; init; } - public DateTime? At { get; init; } + public DateTimeOffset? At { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs index 90e69730..7229f0ad 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs @@ -7,10 +7,6 @@ public sealed record LogoutRequest public string? TenantId { get; init; } public AuthSessionId SessionId { get; init; } - /// - /// Optional logical timestamp for the logout operation. - /// If not provided, the flow service will use DateTime.UtcNow. - /// - public DateTime? At { get; init; } + public DateTimeOffset? At { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs index 6422fbb2..a46570e3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs @@ -11,7 +11,7 @@ public sealed class AuthenticatedSessionContext public string? TenantId { get; init; } public required TUserId UserId { get; init; } public DeviceInfo DeviceInfo { get; init; } - public DateTime Now { get; init; } + public DateTimeOffset Now { get; init; } public ClaimsSnapshot? Claims { get; init; } public SessionMetadata Metadata { 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..91896640 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs @@ -0,0 +1,38 @@ +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 ISession? Session { get; init; } + public ISessionChain? 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( + ISession session, + ISessionChain chain) + => new() + { + IsValid = true, + Session = session, + Chain = chain + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs index d207f59d..f3f7d934 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -1,10 +1,21 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed record SessionRefreshResult { public AccessToken AccessToken { get; init; } = default!; public RefreshToken? RefreshToken { get; init; } + + public bool IsValid => AccessToken is not null; + + private SessionRefreshResult() { } + + public static SessionRefreshResult Success(AccessToken accessToken, RefreshToken? refreshToken) + => new() + { + AccessToken = accessToken, + RefreshToken = refreshToken + }; + + public static SessionRefreshResult Invalid() => new(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs index 2510afaa..874e4b7e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -7,7 +7,7 @@ public sealed record SessionRotationContext public string? TenantId { get; init; } public AuthSessionId CurrentSessionId { get; init; } public TUserId UserId { get; init; } - public DateTime Now { get; init; } + public DateTimeOffset Now { get; init; } public DeviceInfo Device { get; init; } public ClaimsSnapshot Claims { get; init; } public SessionMetadata Metadata { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs index aa8b3dd7..85a39968 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs @@ -6,7 +6,7 @@ public sealed record SessionValidationContext { public string? TenantId { get; init; } public AuthSessionId SessionId { get; init; } - public DateTime Now { get; init; } + public DateTimeOffset Now { get; init; } public DeviceInfo Device { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs new file mode 100644 index 00000000..8e4cefc1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum RefreshTokenFailureReason + { + Invalid, + Expired, + Revoked, + Reused + } +} 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..68641efb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record RefreshTokenValidationResult + { + public bool IsValid { get; init; } + + public TUserId? UserId { get; init; } + + public AuthSessionId? SessionId { get; init; } + + private RefreshTokenValidationResult() { } + + public static RefreshTokenValidationResult Invalid() + => new() + { + IsValid = false + }; + + public static RefreshTokenValidationResult Valid( + TUserId userId, + AuthSessionId sessionId) + => new() + { + IsValid = true, + UserId = userId, + SessionId = sessionId + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs index ad3dc768..c4cb22fd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs @@ -6,6 +6,6 @@ public sealed record TokenIssueContext { public string? TenantId { get; init; } public ISession Session { get; init; } = default!; - public DateTime Now { get; init; } + public DateTimeOffset At { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs index b8e7c29b..15485523 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs @@ -12,7 +12,7 @@ public sealed record TokenValidationResult public AuthSessionId? SessionId { get; init; } public IReadOnlyCollection Claims { get; init; } = Array.Empty(); public TokenInvalidReason? InvalidReason { get; init; } - public DateTime? ExpiresAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } private TokenValidationResult( bool isValid, @@ -22,7 +22,7 @@ private TokenValidationResult( AuthSessionId? sessionId, IReadOnlyCollection? claims, TokenInvalidReason? invalidReason, - DateTime? expiresAt + DateTimeOffset? expiresAt ) { IsValid = isValid; @@ -40,7 +40,7 @@ public static TokenValidationResult Valid( TUserId userId, AuthSessionId? sessionId, IReadOnlyCollection claims, - DateTime? expiresAt) + DateTimeOffset? expiresAt) => new( isValid: true, type, diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs new file mode 100644 index 00000000..e921add4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public readonly struct Unit + { + public static readonly Unit Value = new(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs index 967032b3..292898b6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs @@ -41,6 +41,10 @@ public ChainId(Guid value) /// true if the object is a with the same value. public override bool Equals(object? obj) => obj is ChainId other && Equals(other); + public static bool operator ==(ChainId left, ChainId right) => left.Equals(right); + + public static bool operator !=(ChainId left, ChainId right) => !left.Equals(right); + /// /// Returns a hash code based on the underlying GUID value. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs index 232e13ac..1fcb8458 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -12,26 +12,30 @@ public interface ISession /// AuthSessionId SessionId { get; } + string? TenantId { get; } + /// /// Gets the identifier of the user who owns this session. /// TUserId UserId { get; } + ChainId ChainId { get; } + /// /// Gets the timestamp when this session was originally created. /// - DateTime CreatedAt { get; } + DateTimeOffset CreatedAt { get; } /// /// Gets the timestamp when the session becomes invalid due to expiration. /// - DateTime ExpiresAt { get; } + DateTimeOffset ExpiresAt { get; } /// /// Gets the timestamp of the last successful usage. /// Used when evaluating sliding expiration policies. /// - DateTime? LastSeenAt { get; } + DateTimeOffset? LastSeenAt { get; } /// /// Gets a value indicating whether this session has been explicitly revoked. @@ -41,7 +45,7 @@ public interface ISession /// /// Gets the timestamp when the session was revoked, if applicable. /// - DateTime? RevokedAt { get; } + DateTimeOffset? RevokedAt { get; } /// /// Gets the user's security version at the moment of session creation. @@ -70,10 +74,10 @@ public interface ISession /// /// Current timestamp used for comparisons. /// The evaluated of this session. - SessionState GetState(DateTime now); + SessionState GetState(DateTimeOffset now); - bool ShouldUpdateLastSeen(DateTime now); - ISession Touch(DateTime now); + bool ShouldUpdateLastSeen(DateTimeOffset now); + ISession Touch(DateTimeOffset now); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs index 358beb85..da2b8d89 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs @@ -52,11 +52,11 @@ public interface ISessionChain /// /// Gets the timestamp when the chain was revoked, if applicable. /// - DateTime? RevokedAt { get; } + DateTimeOffset? RevokedAt { get; } ISessionChain AttachSession(AuthSessionId sessionId); ISessionChain RotateSession(AuthSessionId sessionId); - ISessionChain Revoke(DateTime at); + ISessionChain Revoke(DateTimeOffset at); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs index afc7d14d..c51ba8e3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs @@ -29,7 +29,7 @@ public interface ISessionRoot /// /// Gets the timestamp when the session root was revoked, if applicable. /// - DateTime? RevokedAt { get; } + DateTimeOffset? RevokedAt { get; } /// /// Gets the current security version of the user within this tenant. @@ -49,6 +49,10 @@ public interface ISessionRoot /// Gets the timestamp when this root structure was last updated. /// Useful for caching, concurrency handling, and incremental synchronization. /// - DateTime LastUpdatedAt { get; } + DateTimeOffset LastUpdatedAt { get; } + + ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at); + + ISessionRoot Revoke(DateTimeOffset at); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index a56fcd2a..c3c0d42c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -5,11 +5,12 @@ public sealed class UAuthSession : ISession public AuthSessionId SessionId { get; } public string? TenantId { get; } public TUserId UserId { get; } - public DateTime CreatedAt { get; } - public DateTime ExpiresAt { get; } - public DateTime? LastSeenAt { get; } + public ChainId ChainId { get; } + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset ExpiresAt { get; } + public DateTimeOffset? LastSeenAt { get; } public bool IsRevoked { get; } - public DateTime? RevokedAt { get; } + public DateTimeOffset? RevokedAt { get; } public long SecurityVersionAtCreation { get; } public DeviceInfo Device { get; } public ClaimsSnapshot Claims { get; } @@ -19,11 +20,12 @@ private UAuthSession( AuthSessionId sessionId, string? tenantId, TUserId userId, - DateTime createdAt, - DateTime expiresAt, - DateTime? lastSeenAt, + ChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + DateTimeOffset? lastSeenAt, bool isRevoked, - DateTime? revokedAt, + DateTimeOffset? revokedAt, long securityVersionAtCreation, DeviceInfo device, ClaimsSnapshot claims, @@ -32,6 +34,7 @@ private UAuthSession( SessionId = sessionId; TenantId = tenantId; UserId = userId; + ChainId = chainId; CreatedAt = createdAt; ExpiresAt = expiresAt; LastSeenAt = lastSeenAt; @@ -47,8 +50,9 @@ public static UAuthSession Create( AuthSessionId sessionId, string? tenantId, TUserId userId, - DateTime now, - DateTime expiresAt, + ChainId chainId, + DateTimeOffset now, + DateTimeOffset expiresAt, DeviceInfo device, ClaimsSnapshot claims, SessionMetadata metadata) @@ -57,6 +61,7 @@ public static UAuthSession Create( sessionId, tenantId, userId, + chainId, createdAt: now, expiresAt: expiresAt, lastSeenAt: now, @@ -78,6 +83,7 @@ public UAuthSession WithSecurityVersion(long version) SessionId, TenantId, UserId, + ChainId, CreatedAt, ExpiresAt, LastSeenAt, @@ -90,26 +96,27 @@ public UAuthSession WithSecurityVersion(long version) ); } - public bool ShouldUpdateLastSeen(DateTime now) + public bool ShouldUpdateLastSeen(DateTimeOffset at) { if (LastSeenAt is null) return true; - return (now - LastSeenAt.Value) >= TimeSpan.FromMinutes(1); + return (at - LastSeenAt.Value) >= TimeSpan.FromMinutes(1); } - public ISession Touch(DateTime now) + public ISession Touch(DateTimeOffset at) { - if (!ShouldUpdateLastSeen(now)) + if (!ShouldUpdateLastSeen(at)) return this; return new UAuthSession( SessionId, TenantId, UserId, + ChainId, CreatedAt, ExpiresAt, - now, + at, IsRevoked, RevokedAt, SecurityVersionAtCreation, @@ -119,7 +126,7 @@ public ISession Touch(DateTime now) ); } - public UAuthSession Revoke(DateTime at) + public UAuthSession Revoke(DateTimeOffset at) { if (IsRevoked) return this; @@ -127,6 +134,7 @@ public UAuthSession Revoke(DateTime at) SessionId, TenantId, UserId, + ChainId, CreatedAt, ExpiresAt, LastSeenAt, @@ -139,10 +147,10 @@ public UAuthSession Revoke(DateTime at) ); } - public SessionState GetState(DateTime now) + public SessionState GetState(DateTimeOffset at) { if (IsRevoked) return SessionState.Revoked; - if (now >= ExpiresAt) return SessionState.Expired; + if (at >= ExpiresAt) return SessionState.Expired; return SessionState.Active; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 1afa3aa1..70849ea2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -10,7 +10,7 @@ public sealed class UAuthSessionChain : ISessionChain public ClaimsSnapshot ClaimsSnapshot { get; } public AuthSessionId? ActiveSessionId { get; } public bool IsRevoked { get; } - public DateTime? RevokedAt { get; } + public DateTimeOffset? RevokedAt { get; } private UAuthSessionChain( ChainId chainId, @@ -21,7 +21,7 @@ private UAuthSessionChain( ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, bool isRevoked, - DateTime? revokedAt) + DateTimeOffset? revokedAt) { ChainId = chainId; TenantId = tenantId; @@ -90,7 +90,7 @@ public ISessionChain RotateSession(AuthSessionId sessionId) ); } - public ISessionChain Revoke(DateTime at) + public ISessionChain Revoke(DateTimeOffset at) { if (IsRevoked) return this; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 0b0be80e..1f6641f5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -5,19 +5,19 @@ public sealed class UAuthSessionRoot : ISessionRoot public TUserId UserId { get; } public string? TenantId { get; } public bool IsRevoked { get; } - public DateTime? RevokedAt { get; } + public DateTimeOffset? RevokedAt { get; } public long SecurityVersion { get; } public IReadOnlyList> Chains { get; } - public DateTime LastUpdatedAt { get; } + public DateTimeOffset LastUpdatedAt { get; } private UAuthSessionRoot( string? tenantId, TUserId userId, bool isRevoked, - DateTime? revokedAt, + DateTimeOffset? revokedAt, long securityVersion, IReadOnlyList> chains, - DateTime lastUpdatedAt) + DateTimeOffset lastUpdatedAt) { TenantId = tenantId; UserId = userId; @@ -28,10 +28,10 @@ private UAuthSessionRoot( LastUpdatedAt = lastUpdatedAt; } - public static UAuthSessionRoot Create( + public static ISessionRoot Create( string? tenantId, TUserId userId, - DateTime issuedAt) + DateTimeOffset issuedAt) { return new UAuthSessionRoot( tenantId, @@ -44,7 +44,7 @@ public static UAuthSessionRoot Create( ); } - public UAuthSessionRoot Revoke(DateTime at) + public ISessionRoot Revoke(DateTimeOffset at) { if (IsRevoked) return this; @@ -60,5 +60,21 @@ public UAuthSessionRoot Revoke(DateTime at) ); } + public ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at) + { + if (IsRevoked) + return this; + + return new UAuthSessionRoot( + TenantId, + UserId, + IsRevoked, + RevokedAt, + SecurityVersion, + Chains.Concat(new[] { chain }).ToArray(), + at + ); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs new file mode 100644 index 00000000..fd91893a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs @@ -0,0 +1,19 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Represents a persisted refresh token bound to a session. + /// Stored as a hashed value for security reasons. + /// + public sealed record StoredRefreshToken + { + public string TokenHash { get; init; } = default!; + + public AuthSessionId SessionId { get; init; } = default!; + + public DateTimeOffset ExpiresAt { get; init; } + + public DateTimeOffset? RevokedAt { get; init; } + + public bool IsRevoked => RevokedAt.HasValue; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs index 84bc03cb..c272e507 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs @@ -13,7 +13,7 @@ public sealed class UAuthJwtTokenDescriptor public required string Audience { get; init; } - public required DateTime Expires { get; init; } + public required DateTimeOffset Expires { get; init; } /// /// Signing key material (symmetric or asymmetric). 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..e927d419 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthChallengeRequiredException : UAuthException + { + public UAuthChallengeRequiredException(string? reason = null) + : base(reason ?? "Additional authentication is required to perform this operation.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs new file mode 100644 index 00000000..f7bbf0ee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthAuthorizationException : UAuthException + { + public UAuthAuthorizationException(string? reason = null) + : base(reason ?? "The current principal is not authorized to perform this operation.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs index 532c86a0..a989b867 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs @@ -32,12 +32,12 @@ public sealed class SessionCreatedContext : IAuthEventContext /// /// Gets the timestamp on which the session was created. /// - public DateTime CreatedAt { get; } + public DateTimeOffset CreatedAt { get; } /// /// Initializes a new instance of the class. /// - public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, ChainId chainId, DateTime createdAt) + public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, ChainId chainId, DateTimeOffset createdAt) { UserId = userId; SessionId = sessionId; diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs index 85ab19e2..0c0ce5fb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs @@ -38,7 +38,7 @@ public sealed class SessionRefreshedContext : IAuthEventContext /// /// Gets the timestamp at which the refresh occurred. /// - public DateTime RefreshedAt { get; } + public DateTimeOffset RefreshedAt { get; } /// /// Initializes a new instance of the class. @@ -48,7 +48,7 @@ public SessionRefreshedContext( AuthSessionId oldSessionId, AuthSessionId newSessionId, ChainId chainId, - DateTime refreshedAt) + DateTimeOffset refreshedAt) { UserId = userId; OldSessionId = oldSessionId; diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs index bd19b7e2..ee7e98a2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs @@ -36,7 +36,7 @@ public sealed class SessionRevokedContext : IAuthEventContext /// /// Gets the timestamp at which the session revocation occurred. /// - public DateTime RevokedAt { get; } + public DateTimeOffset RevokedAt { get; } /// /// Initializes a new instance of the class. @@ -45,7 +45,7 @@ public SessionRevokedContext( TUserId userId, AuthSessionId sessionId, ChainId chainId, - DateTime revokedAt) + DateTimeOffset revokedAt) { UserId = userId; SessionId = sessionId; diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs index f6f4af0f..b661db79 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs @@ -28,12 +28,12 @@ public sealed class UserLoggedInContext : IAuthEventContext /// /// Gets the timestamp at which the login event occurred. /// - public DateTime LoggedInAt { get; } + public DateTimeOffset LoggedInAt { get; } /// /// Initializes a new instance of the class. /// - public UserLoggedInContext(TUserId userId, DateTime at) + public UserLoggedInContext(TUserId userId, DateTimeOffset at) { UserId = userId; LoggedInAt = at; diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs index 311a87cc..6f6e707f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs @@ -27,12 +27,12 @@ public sealed class UserLoggedOutContext : IAuthEventContext /// /// Gets the timestamp at which the logout occurred. /// - public DateTime LoggedOutAt { get; } + public DateTimeOffset LoggedOutAt { get; } /// /// Initializes a new instance of the class. /// - public UserLoggedOutContext(TUserId userId, DateTime at) + public UserLoggedOutContext(TUserId userId, DateTimeOffset at) { UserId = userId; LoggedOutAt = at; diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs new file mode 100644 index 00000000..4b5ca72b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs @@ -0,0 +1,43 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class DefaultAuthAuthority : IAuthAuthority + { + private readonly IEnumerable _invariants; + private readonly IEnumerable _policies; + + public AuthorizationResult Decide(AuthContext context) + { + // 1. Invariants + foreach (var invariant in _invariants) + { + var result = invariant.Decide(context); + if (!result.IsAllowed) + return result; + } + + // 2. Policies + bool challenged = false; + + foreach (var policy in _policies) + { + if (!policy.AppliesTo(context)) + continue; + + var result = policy.Decide(context); + + if (!result.IsAllowed) + return result; + + if (result.RequiresChallenge) + challenged = true; + } + + return challenged + ? AuthorizationResult.Challenge("Additional verification required.") + : AuthorizationResult.Allow(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs new file mode 100644 index 00000000..8bc3abc5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class DeviceTrustPolicy : IAuthorityPolicy + { + public bool AppliesTo(AuthContext context) => context.Device is not null; + + public AuthorizationResult Decide(AuthContext context) + { + var device = context.Device; + + if (device.IsTrusted) + return AuthorizationResult.Allow(); + + return context.Operation switch + { + AuthOperation.Login => + AuthorizationResult.Challenge("Login from untrusted device requires additional verification."), + + AuthOperation.Refresh => + AuthorizationResult.Challenge("Token refresh from untrusted device requires additional verification."), + + AuthOperation.Access => + AuthorizationResult.Deny("Access from untrusted device is not allowed."), + + _ => AuthorizationResult.Allow() + }; + } + } +} 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..a3eedf60 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs @@ -0,0 +1,27 @@ +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 AuthorizationResult Decide(AuthContext context) + { + if (context.Operation == AuthOperation.Login) + return AuthorizationResult.Allow(); + + var session = context.Session; + + if (session is null) + return AuthorizationResult.Allow(); + + if (session.State == SessionState.Expired) + { + return AuthorizationResult.Deny("Session has expired."); + } + + return AuthorizationResult.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..1929bb5a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs @@ -0,0 +1,31 @@ +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 AuthorizationResult Decide(AuthContext context) + { + if (context.Operation == AuthOperation.Login) + return AuthorizationResult.Allow(); + + var session = context.Session; + + if (session is null) + return AuthorizationResult.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 AuthorizationResult.Deny($"Session state is invalid: {session.State}"); + } + + return AuthorizationResult.Allow(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs new file mode 100644 index 00000000..50960413 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs @@ -0,0 +1,39 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class AuthModeOperationPolicy : IAuthorityPolicy + { + public bool AppliesTo(AuthContext context) => true; // Applies to all contexts + + public AuthorizationResult Decide(AuthContext context) + { + return context.Mode switch + { + UAuthMode.PureOpaque => DecideForPureOpaque(context), + UAuthMode.PureJwt => DecideForPureJwt(context), + UAuthMode.Hybrid => AuthorizationResult.Allow(), + UAuthMode.SemiHybrid => AuthorizationResult.Allow(), + + _ => AuthorizationResult.Deny("Unsupported authentication mode.") + }; + } + + private static AuthorizationResult DecideForPureOpaque(AuthContext context) + { + if (context.Operation == AuthOperation.Refresh) + return AuthorizationResult.Deny("Refresh operation is not supported in PureOpaque mode."); + + return AuthorizationResult.Allow(); + } + + private static AuthorizationResult DecideForPureJwt(AuthContext context) + { + if (context.Operation == AuthOperation.Access) + return AuthorizationResult.Deny("Session-based access is not supported in PureJwt mode."); + + return AuthorizationResult.Allow(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs new file mode 100644 index 00000000..cbbbfc2f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs @@ -0,0 +1,83 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + internal sealed class StoreRefreshTokenResolver + : IRefreshTokenResolver + { + private readonly ISessionStoreFactory _sessionStoreFactory; + private readonly ITokenStoreFactory _tokenStoreFactory; + private readonly ITokenHasher _hasher; + + public StoreRefreshTokenResolver( + ISessionStoreFactory sessionStoreFactory, + ITokenStoreFactory tokenStoreFactory, + ITokenHasher hasher) + { + _sessionStoreFactory = sessionStoreFactory; + _tokenStoreFactory = tokenStoreFactory; + _hasher = hasher; + } + + public async Task?> ResolveAsync( + string? tenantId, + string refreshToken, + DateTimeOffset now, + CancellationToken ct = default) + { + var tokenHash = _hasher.Hash(refreshToken); + + var tokenStore = _tokenStoreFactory.Create(tenantId); + var sessionStore = _sessionStoreFactory.Create(tenantId); + + var stored = await tokenStore.GetRefreshTokenAsync( + tenantId, + tokenHash); + + if (stored is null) + return null; + + if (stored.IsRevoked) + { + return ResolvedRefreshSession.Reused(); + } + + if (stored.ExpiresAt <= now) + { + await tokenStore.RevokeRefreshTokenAsync( + tenantId, + tokenHash, + now); + + return ResolvedRefreshSession.Invalid(); + } + + var session = await sessionStore.GetSessionAsync( + tenantId, + stored.SessionId); + + if (session is null) + return null; + + if (session.IsRevoked || session.ExpiresAt <= now) + return null; + + var chain = await sessionStore.GetChainAsync( + tenantId, + session.ChainId); + + if (chain is null || chain.IsRevoked) + return null; + + await tokenStore.RevokeRefreshTokenAsync( + tenantId, + tokenHash, + now); + + return ResolvedRefreshSession.Valid( + session, + chain); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs index 3f80324d..a5ad9a10 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs @@ -10,7 +10,7 @@ public sealed class UserRecord public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; public bool RequiresMfa { get; init; } public bool IsActive { get; init; } = true; - public DateTime CreatedAt { get; init; } + public DateTimeOffset CreatedAt { get; init; } public bool IsDeleted { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs new file mode 100644 index 00000000..48656db7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Contracts +{ + public sealed record LoginResponse + { + public string? SessionId { get; init; } + public AccessToken? AccessToken { get; init; } + public RefreshToken? RefreshToken { get; init; } + public object? Continuation { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs new file mode 100644 index 00000000..2f804228 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Server.Contracts +{ + public sealed record LogoutResponse + { + public bool Success { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index cb63cef0..56c1eaca 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -1,7 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Contracts; using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.MultiTenancy; using Microsoft.AspNetCore.Http; @@ -30,7 +32,8 @@ public async Task LoginAsync(HttpContext ctx) if (request is null) return Results.BadRequest("Invalid login request."); - var tenantCtx = await _tenantResolver.ResolveAsync(ctx); + // Middleware should have already resolved the tenant + var tenantCtx = ctx.GetTenantContext(); var flowRequest = request with { @@ -43,18 +46,21 @@ public async Task LoginAsync(HttpContext ctx) return result.Status switch { - LoginStatus.Success => Results.Ok(new + LoginStatus.Success => Results.Ok(new LoginResponse { - sessionId = result.SessionId, - accessToken = result.AccessToken, - refreshToken = result.RefreshToken + SessionId = result.SessionId, + AccessToken = result.AccessToken, + RefreshToken = result.RefreshToken }), - LoginStatus.RequiresContinuation => Results.Accepted(null, result.Continuation), + LoginStatus.RequiresContinuation => Results.Ok(new LoginResponse + { + Continuation = result.Continuation + }), LoginStatus.Failed => Results.Unauthorized(), - _ => Results.StatusCode(500) + _ => Results.StatusCode(StatusCodes.Status500InternalServerError) }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs new file mode 100644 index 00000000..8a13f4f2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs @@ -0,0 +1,43 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public sealed class DefaultLogoutEndpointHandler : ILogoutEndpointHandler + { + private readonly IUAuthFlowService _flow; + private readonly IClock _clock; + + public DefaultLogoutEndpointHandler(IUAuthFlowService flow, IClock clock) + { + _flow = flow; + _clock = clock; + } + + public async Task LogoutAsync(HttpContext ctx) + { + var tenantCtx = ctx.GetTenantContext(); + var sessionCtx = ctx.GetSessionContext(); + + if (sessionCtx.IsAnonymous) + return Results.Unauthorized(); + + var request = new LogoutRequest + { + TenantId = tenantCtx.TenantId, + SessionId = sessionCtx.SessionId!.Value, + At = _clock.UtcNow + }; + + await _flow.LogoutAsync(request, ctx.RequestAborted); + + return Results.Ok(new LogoutResponse + { + Success = true + }); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs deleted file mode 100644 index b388f556..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs +++ /dev/null @@ -1,30 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal interface ISessionOrchestrator - { - Task> CreateLoginSessionAsync(AuthenticatedSessionContext context); - - Task> RotateSessionAsync(SessionRotationContext context); - - Task> ValidateSessionAsync(SessionValidationContext context); - - Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId); - - Task>> GetChainsAsync(string? tenantId, TUserId userId); - - Task ResolveChainIdAsync(string? tenantId,AuthSessionId sessionId); - - Task>> GetSessionsAsync(string? tenantId, ChainId chainId); - - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); - - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); - - Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId,DateTime at); - - Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at); - } -} 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..98496b7c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand> + { + public Task> ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + { + return issuer.IssueLoginSessionAsync(LoginContext, ct); + } + } +} 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..5139b34a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs @@ -0,0 +1,10 @@ +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..8cedd4dc --- /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/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs new file mode 100644 index 00000000..a271c0d7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface ISessionQueryService + { + Task> ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default); + + Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); + + Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default); + + Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + + Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, 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..eacdfe7c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs @@ -0,0 +1,24 @@ +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 TUserId UserId { get; } + public ChainId? ExceptChainId { get; } + + public RevokeAllChainsCommand(TUserId userId, ChainId? exceptChainId) + { + UserId = userId; + ExceptChainId = exceptChainId; + } + + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + { + await issuer.RevokeAllChainsAsync(context.TenantId, UserId, 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..0815f98a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator +{ + public sealed class RevokeChainCommand : ISessionCommand + { + public ChainId ChainId { get; } + + public RevokeChainCommand(ChainId chainId) + { + ChainId = chainId; + } + + public async Task ExecuteAsync( + AuthContext context, + ISessionIssuer issuer, + CancellationToken ct) + { + await issuer.RevokeChainAsync( + context.TenantId, + 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..8aa0702d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator +{ + public sealed class RevokeRootCommand : ISessionCommand + { + public TUserId UserId { get; } + + public RevokeRootCommand(TUserId userId) + { + UserId = userId; + } + + public async Task ExecuteAsync( + AuthContext context, + ISessionIssuer issuer, + CancellationToken ct) + { + await issuer.RevokeRootAsync( + context.TenantId, + UserId, + 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..3d88afea --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed record RevokeSessionCommand(string? TenantId, AuthSessionId SessionId) : ISessionCommand + { + public async Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + { + await issuer.RevokeSessionAsync(TenantId, SessionId, _.At, ct); + return Unit.Value; + } + } +} 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..d57b479e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs @@ -0,0 +1,13 @@ +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/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs new file mode 100644 index 00000000..784df9b9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -0,0 +1,44 @@ +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 UAuthAuthorizationException(decision.Reason); + + 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/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs new file mode 100644 index 00000000..d590374d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs @@ -0,0 +1,101 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class UAuthSessionQueryService + : ISessionQueryService + { + private readonly ISessionStoreFactory _storeFactory; + + public UAuthSessionQueryService(ISessionStoreFactory storeFactory) + { + _storeFactory = storeFactory; + } + + public async Task> ValidateSessionAsync( + SessionValidationContext context, + CancellationToken ct = default) + { + var kernel = _storeFactory.Create(context.TenantId); + + var session = await kernel.GetSessionAsync( + context.TenantId, + context.SessionId); + + if (session is null) + return SessionValidationResult.Invalid(SessionState.NotFound); + + var state = session.GetState(context.Now); + if (state != SessionState.Active) + return SessionValidationResult.Invalid(state); + + var chain = await kernel.GetChainAsync( + context.TenantId, + session.ChainId); + + if (chain is null || chain.IsRevoked) + return SessionValidationResult.Invalid(SessionState.Revoked); + + var root = await kernel.GetSessionRootAsync( + context.TenantId, + session.UserId); + + if (root is null || root.IsRevoked) + return SessionValidationResult.Invalid(SessionState.Revoked); + + if (session.SecurityVersionAtCreation != root.SecurityVersion) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch); + + if (!session.Device.Matches(context.Device)) + return SessionValidationResult.Invalid(SessionState.DeviceMismatch); + + if (session.ShouldUpdateLastSeen(context.Now)) + { + var updated = session.Touch(context.Now); + await kernel.SaveSessionAsync(context.TenantId, updated); + session = updated; + } + + return SessionValidationResult.Active(session, chain, root); + } + + public Task?> GetSessionAsync( + string? tenantId, + AuthSessionId sessionId, + CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenantId); + return kernel.GetSessionAsync(tenantId, sessionId); + } + + public Task>> GetSessionsByChainAsync( + string? tenantId, + ChainId chainId, + CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenantId); + return kernel.GetSessionsByChainAsync(tenantId, chainId); + } + + public Task>> GetChainsByUserAsync( + string? tenantId, + TUserId userId, + CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenantId); + return kernel.GetChainsByUserAsync(tenantId, userId); + } + + public Task ResolveChainIdAsync( + string? tenantId, + AuthSessionId sessionId, + CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenantId); + return kernel.GetChainIdBySessionAsync(tenantId, sessionId); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs index 5a3b0642..c6e526a6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs @@ -4,6 +4,6 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class SystemClock : IClock { - public DateTime UtcNow => DateTime.UtcNow; + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs index 67e8ea61..4aff464a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs @@ -8,6 +8,6 @@ public sealed record TokenIssuanceContext public string? TenantId { get; init; } public IReadOnlyCollection Claims { get; init; } = Array.Empty(); public string? SessionId { get; init; } - public DateTime IssuedAt { get; init; } + public DateTimeOffset IssuedAt { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs deleted file mode 100644 index 7bcd741e..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs +++ /dev/null @@ -1,337 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Errors; -using CodeBeam.UltimateAuth.Server.Issuers; -using CodeBeam.UltimateAuth.Server.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - /// - /// Default UltimateAuth session store implementation. - /// Handles session, chain, and root orchestration on top of a kernel store. - /// - public sealed class UAuthSessionOrchestrator : ISessionOrchestrator - { - private readonly ISessionStoreFactory _factory; - private readonly UAuthSessionIssuer _sessionIssuer; - private readonly UAuthServerOptions _serverOptions; - - public UAuthSessionOrchestrator(ISessionStoreFactory factory, UAuthSessionIssuer sessionIssuer, UAuthServerOptions serverOptions) - { - _factory = factory; - _sessionIssuer = sessionIssuer; - _serverOptions = serverOptions; - } - - public async Task> CreateLoginSessionAsync(AuthenticatedSessionContext context) - { - var kernel = _factory.Create(context.TenantId); - - var root = await kernel.GetSessionRootAsync( - context.TenantId, - context.UserId); - - if (root is null) - { - root = UAuthSessionRoot.Create( - context.TenantId, - context.UserId, - context.Now); - } - else if (root.IsRevoked) - { - throw new UAuthSessionRootRevokedException(context.UserId!); - } - - ISessionChain chain; - - if (context.ChainId is not null) - { - chain = await kernel.GetChainAsync( - context.TenantId, - context.ChainId.Value) - ?? throw new UAuthSessionChainNotFoundException( - context.ChainId.Value); - - if (chain.IsRevoked) - throw new UAuthSessionChainRevokedException( - chain.ChainId); - } - else - { - chain = UAuthSessionChain.Create( - ChainId.New(), - context.TenantId, - context.UserId, - root.SecurityVersion, - context.Claims); - } - - var issuedSession = await _sessionIssuer.IssueAsync( - context, - chain); - - await kernel.ExecuteAsync(async () => - { - await kernel.SaveSessionAsync( - context.TenantId, - issuedSession.Session); - - var updatedChain = chain.AttachSession( - issuedSession.Session.SessionId); - - await kernel.SaveChainAsync( - context.TenantId, - updatedChain); - - await kernel.SaveSessionRootAsync( - context.TenantId, - root); - }); - - return issuedSession; - } - - public async Task> RotateSessionAsync(SessionRotationContext context) - { - var kernel = _factory.Create(context.TenantId); - - var currentSession = await kernel.GetSessionAsync( - context.TenantId, - context.CurrentSessionId); - - if (currentSession is null) - throw new UAuthSessionNotFoundException(context.CurrentSessionId); - - if (currentSession.IsRevoked) - throw new UAuthSessionRevokedException(context.CurrentSessionId); - - var state = currentSession.GetState(context.Now); - if (state != SessionState.Active) - throw new UAuthSessionInvalidStateException( - context.CurrentSessionId, state); - - var chainId = await kernel.GetChainIdBySessionAsync( - context.TenantId, - context.CurrentSessionId); - - if (chainId is null) - throw new UAuthSessionChainLinkMissingException(context.CurrentSessionId); - - var chain = await kernel.GetChainAsync( - context.TenantId, - chainId.Value); - - if (chain is null || chain.IsRevoked) - throw new UAuthSessionChainRevokedException(chainId.Value); - - var root = await kernel.GetSessionRootAsync( - context.TenantId, - currentSession.UserId); - - if (root is null || root.IsRevoked) - throw new UAuthSessionRootRevokedException( - currentSession.UserId!); - - if (currentSession.SecurityVersionAtCreation != root.SecurityVersion) - throw new UAuthSessionSecurityMismatchException( - context.CurrentSessionId, - root.SecurityVersion); - - var issueContext = new AuthenticatedSessionContext - { - TenantId = root.TenantId, - UserId = currentSession.UserId, - Now = context.Now, - DeviceInfo = context.Device, - Claims = context.Claims - }; - - var issuedSession = await _sessionIssuer.IssueAsync( - issueContext, - chain); - - await kernel.ExecuteAsync(async () => - { - await kernel.RevokeSessionAsync( - context.TenantId, - context.CurrentSessionId, - context.Now); - - await kernel.SaveSessionAsync( - context.TenantId, - issuedSession.Session); - - var rotatedChain = chain.RotateSession( - issuedSession.Session.SessionId); - - await kernel.SaveChainAsync( - context.TenantId, - rotatedChain); - - await kernel.SaveSessionRootAsync( - context.TenantId, - root); - }); - - return issuedSession; - } - - public async Task> ValidateSessionAsync( - SessionValidationContext context) - { - var kernel = _factory.Create(context.TenantId); - - // 1️⃣ Load session - var session = await kernel.GetSessionAsync( - context.TenantId, - context.SessionId); - - if (session is null) - return SessionValidationResult.Invalid(SessionState.NotFound); - - var state = session.GetState(context.Now); - - if (state != SessionState.Active) - return SessionValidationResult.Invalid(state); - - // 2️⃣ Resolve chain - var chainId = await kernel.GetChainIdBySessionAsync( - context.TenantId, - context.SessionId); - - if (chainId is null) - return SessionValidationResult.Invalid(SessionState.Invalid); - - var chain = await kernel.GetChainAsync( - context.TenantId, - chainId.Value); - - if (chain is null || chain.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked); - - // 3️⃣ Resolve root - var root = await kernel.GetSessionRootAsync( - context.TenantId, - session.UserId); - - if (root is null || root.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked); - - // 4️⃣ Security version check - if (session.SecurityVersionAtCreation != root.SecurityVersion) - return SessionValidationResult.Invalid(SessionState.SecurityMismatch); - - // 5️⃣ Device check - if (!session.Device.Matches(context.Device)) - return SessionValidationResult.Invalid(SessionState.DeviceMismatch); - - // 6️⃣ Touch session (best-effort) - if (session.ShouldUpdateLastSeen(context.Now)) - { - var updated = session.Touch(context.Now); - await kernel.SaveSessionAsync(context.TenantId, updated); - session = updated; - } - - // 7️⃣ Success - return SessionValidationResult.Active( - session, - chain, - root); - } - - public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId) - { - var kernel = _factory.Create(tenantId); - return kernel.GetSessionAsync(tenantId, sessionId); - } - - public Task>> GetSessionsAsync(string? tenantId, ChainId chainId) - { - var kernel = _factory.Create(tenantId); - return kernel.GetSessionsByChainAsync(tenantId, chainId); - } - - public Task>> GetChainsAsync(string? tenantId, TUserId userId) - { - var kernel = _factory.Create(tenantId); - return kernel.GetChainsByUserAsync(tenantId, userId); - } - - public async Task ResolveChainIdAsync( - string? tenantId, - AuthSessionId sessionId) - { - var kernel = _factory.Create(tenantId); - return await kernel.GetChainIdBySessionAsync(tenantId, sessionId); - } - - public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at) - { - var kernel = _factory.Create(tenantId); - await kernel.RevokeSessionAsync(tenantId, sessionId, at); - } - - public async Task RevokeAllSessionsAsync( - string? tenantId, - TUserId userId, - DateTime at) - { - var kernel = _factory.Create(tenantId); - await kernel.RevokeSessionRootAsync(tenantId, userId, at); - } - - public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at) - { - var kernel = _factory.Create(tenantId); - await kernel.RevokeChainAsync(tenantId, chainId, at); - } - - public async Task RevokeAllChainsAsync( - string? tenantId, - TUserId userId, - ChainId? exceptChainId, - DateTime at) - { - var kernel = _factory.Create(tenantId); - - var chains = await kernel.GetChainsByUserAsync(tenantId, userId); - - await kernel.ExecuteAsync(async () => - { - foreach (var chain in chains) - { - if (exceptChainId.HasValue && - chain.ChainId.Equals(exceptChainId.Value)) - { - continue; - } - - if (!chain.IsRevoked) - { - await kernel.RevokeChainAsync( - tenantId, - chain.ChainId, - at); - } - } - }); - } - - public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at) - { - var kernel = _factory.Create(tenantId); - - await kernel.ExecuteAsync(async () => - { - await kernel.RevokeSessionRootAsync( - tenantId, - userId, - at); - }); - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs index 201d6b57..b88bb9c1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs @@ -5,65 +5,278 @@ using CodeBeam.UltimateAuth.Core.Domain.Session; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; +using System.Security; namespace CodeBeam.UltimateAuth.Server.Issuers { - /// - /// UltimateAuth session issuer responsible for creating - /// opaque authentication sessions. - /// public sealed class UAuthSessionIssuer : ISessionIssuer { private readonly IOpaqueTokenGenerator _opaqueGenerator; + private readonly ISessionStoreFactory _storeFactory; private readonly UAuthServerOptions _options; - public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, IOptions options) + public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, ISessionStoreFactory storeFactory, IOptions options) { _opaqueGenerator = opaqueGenerator; + _storeFactory = storeFactory; _options = options.Value; } - // chain is intentionally provided for future policy extensions - public Task> IssueAsync( - AuthenticatedSessionContext context, - ISessionChain chain, - CancellationToken cancellationToken = default) + public async Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default) { + // Defensive guard — enforcement belongs to Authority if (_options.Mode == UAuthMode.PureJwt) { - throw new InvalidOperationException( - "Session issuer cannot be used in PureJwt mode."); + throw new InvalidOperationException("Session issuance is not allowed in PureJwt mode."); } + var now = context.Now; var opaqueSessionId = _opaqueGenerator.Generate(); - var expiresAt = context.Now.Add(_options.Session.Lifetime); + + var expiresAt = now.Add(_options.Session.Lifetime); if (_options.Session.MaxLifetime is not null) { - var absoluteExpiry = - context.Now.Add(_options.Session.MaxLifetime.Value); - + var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value); if (absoluteExpiry < expiresAt) expiresAt = absoluteExpiry; } - var session = UAuthSession.Create( - sessionId: new AuthSessionId(opaqueSessionId), - tenantId: context.TenantId, - userId: context.UserId, - now: context.Now, - expiresAt: expiresAt, - claims: context.Claims, - device: context.DeviceInfo, - metadata: context.Metadata - ); - - return Task.FromResult(new IssuedSession + var store = _storeFactory.Create(context.TenantId); + + IssuedSession? issued = null; + + await store.ExecuteAsync(async () => + { + // Root + var root = + await store.GetSessionRootAsync(context.TenantId, context.UserId) + ?? UAuthSessionRoot.Create( + context.TenantId, + context.UserId, + now); + + // Chain + var claimsSnapshot = context.Claims; + + var chain = UAuthSessionChain.Create( + ChainId.New(), + context.TenantId, + context.UserId, + root.SecurityVersion, + claimsSnapshot); + + root = root.AttachChain(chain, now); + + // Session + var session = UAuthSession.Create( + sessionId: new AuthSessionId(opaqueSessionId), + tenantId: context.TenantId, + userId: context.UserId, + chainId: chain.ChainId, + now: now, + expiresAt: expiresAt, + claims: context.Claims, + device: context.DeviceInfo, + metadata: context.Metadata + ); + + // Persist (order is intentional) + await store.SaveSessionRootAsync(context.TenantId, root); + await store.SaveChainAsync(context.TenantId, chain); + await store.SaveSessionAsync(context.TenantId, session); + await store.SetActiveSessionIdAsync( + context.TenantId, + chain.ChainId, + session.SessionId); + + issued = new IssuedSession + { + Session = session, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }; + }); + + return issued!; + } + + public async Task> RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) + { + var now = context.Now; + var store = _storeFactory.Create(context.TenantId); + + IssuedSession? issued = null; + + await store.ExecuteAsync(async () => { - Session = session, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + var session = await store.GetSessionAsync( + context.TenantId, + context.CurrentSessionId); + + if (session is null) + throw new SecurityException("Session not found."); + + if (session.IsRevoked || session.ExpiresAt <= now) + throw new SecurityException("Session is no longer valid."); + + var chainId = session.ChainId; + + var chain = await store.GetChainAsync( + context.TenantId, + chainId); + + if (chain is null || chain.IsRevoked) + throw new SecurityException("Session chain is invalid."); + + var opaqueSessionId = _opaqueGenerator.Generate(); + + 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 newSession = UAuthSession.Create( + sessionId: new AuthSessionId(opaqueSessionId), + tenantId: session.TenantId, + userId: session.UserId, + chainId: chain.ChainId, + now: now, + expiresAt: expiresAt, + claims: chain.ClaimsSnapshot, + device: session.Device, + metadata: session.Metadata + ); + + await store.SaveSessionAsync(context.TenantId, newSession); + + var rotatedChain = chain.RotateSession(newSession.SessionId); + + await store.SaveChainAsync(context.TenantId, rotatedChain); + await store.SetActiveSessionIdAsync( + context.TenantId, + chain.ChainId, + newSession.SessionId); + + await store.RevokeSessionAsync( + context.TenantId, + session.SessionId, + now); + + issued = new IssuedSession + { + Session = newSession, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }; }); + + return issued!; } + + public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + var store = _storeFactory.Create(tenantId); + + await store.ExecuteAsync(async () => + { + var session = await store.GetSessionAsync(tenantId, sessionId); + if (session is null) + return; + + if (session.IsRevoked) + return; + + await store.RevokeSessionAsync( + tenantId, + sessionId, + at.UtcDateTime); + }); + } + + public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + var store = _storeFactory.Create(tenantId); + + await store.ExecuteAsync(async () => + { + var chain = await store.GetChainAsync(tenantId, chainId); + if (chain is null) + return; + + if (chain.IsRevoked) + return; + + await store.RevokeChainAsync(tenantId, chainId, at.UtcDateTime); + + if (chain.ActiveSessionId is not null) + { + await store.RevokeSessionAsync(tenantId, chain.ActiveSessionId.Value, at.UtcDateTime); + } + }); + } + + public async Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) + { + var store = _storeFactory.Create(tenantId); + + await store.ExecuteAsync(async () => + { + var root = await store.GetSessionRootAsync(tenantId, userId); + if (root is null) + return; + + foreach (var chain in root.Chains) + { + if (exceptChainId.HasValue && chain.ChainId.Equals(exceptChainId.Value)) + { + continue; + } + + await store.RevokeChainAsync(tenantId, chain.ChainId, at.UtcDateTime); + + if (chain.ActiveSessionId is not null) + { + await store.RevokeSessionAsync(tenantId, chain.ActiveSessionId.Value, at.UtcDateTime); + } + } + + await store.SaveSessionRootAsync(tenantId, root); + }); + } + + public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + { + var store = _storeFactory.Create(tenantId); + + await store.ExecuteAsync(async () => + { + var root = await store.GetSessionRootAsync(tenantId, userId); + if (root is null) + return; + + var revokedRoot = root.Revoke(at); + + await store.SaveSessionRootAsync(tenantId, revokedRoot); + + foreach (var chain in root.Chains) + { + await store.RevokeChainAsync(tenantId, chain.ChainId, at); + + if (chain.ActiveSessionId is not null) + { + await store.RevokeSessionAsync( + tenantId, + chain.ActiveSessionId.Value, + at); + } + } + }); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 084e3b00..f2d4beb8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -1,23 +1,31 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Services { internal sealed class UAuthFlowService : IUAuthFlowService { private readonly IUAuthUserService _users; - private readonly IUAuthSessionService _sessions; - private readonly IUAuthTokenService _tokens; + private readonly ISessionOrchestrator _orchestrator; + private readonly ISessionQueryService _queries; + private readonly ITokenIssuer _tokens; + private readonly IRefreshTokenResolver _refreshTokens; public UAuthFlowService( IUAuthUserService users, - IUAuthSessionService sessions, - IUAuthTokenService tokens) + ISessionOrchestrator orchestrator, + ISessionQueryService queries, + ITokenIssuer tokens, + IRefreshTokenResolver refreshTokens) { _users = users; - _sessions = sessions; + _orchestrator = orchestrator; + _queries = queries; _tokens = tokens; + _refreshTokens = refreshTokens; } public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) @@ -47,87 +55,122 @@ public Task ExternalLoginAsync(ExternalLoginRequest request, Cancel public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) { - var now = request.At ?? DateTime.UtcNow; + var now = request.At ?? DateTimeOffset.UtcNow; var device = request.DeviceInfo ?? DeviceInfo.Unknown; - var authResult = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); + // 1️⃣ Authenticate user (NO session yet) + var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); - if (!authResult.Succeeded) - { + if (!auth.Succeeded) return LoginResult.Failed(); - } - var sessionResult = await _sessions.IssueSessionAfterAuthenticationAsync(request.TenantId, - new AuthenticatedSessionContext - { - TenantId = request.TenantId, - UserId = authResult.UserId!, - Now = now, - DeviceInfo = device, - Claims = authResult.Claims, - ChainId = request.ChainId - }); + // 2️⃣ Create authenticated context + var sessionContext = new AuthenticatedSessionContext + { + TenantId = request.TenantId, + UserId = auth.UserId!, + Now = now, + DeviceInfo = device, + Claims = auth.Claims, + ChainId = request.ChainId + }; + + var authContext = AuthContext.ForAuthenticatedUser( + request.TenantId, + AuthOperation.Login, + now, + DeviceContext.From(device)); + + // 3️⃣ Issue session THROUGH orchestrator + var issuedSession = await _orchestrator.ExecuteAsync( + authContext, + new CreateLoginSessionCommand(sessionContext), + ct); + // 4️⃣ Optional tokens AuthTokens? tokens = null; if (request.RequestTokens) { - tokens = await _tokens.CreateTokensAsync( - new TokenIssueContext + var access = await _tokens.IssueAccessTokenAsync( + new TokenIssuanceContext { TenantId = request.TenantId, - Session = sessionResult.Session, - Now = now + UserId = auth.UserId!.ToString()!, + SessionId = issuedSession.Session.SessionId }, ct); + + var refresh = await _tokens.IssueRefreshTokenAsync( + new TokenIssuanceContext + { + TenantId = request.TenantId, + UserId = auth.UserId!.ToString()!, + SessionId = issuedSession.Session.SessionId + }, + ct); + + tokens = new AuthTokens { AccessToken = access, RefreshToken = refresh }; } - return LoginResult.Success(sessionResult.Session.SessionId, tokens); + return LoginResult.Success(issuedSession.Session.SessionId, tokens); } - public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) { - var at = request.At ?? DateTime.UtcNow; - await _sessions.RevokeSessionAsync(request.TenantId, request.SessionId, at); + var now = request.At ?? DateTimeOffset.UtcNow; + var authContext = AuthContext.System(request.TenantId, AuthOperation.Revoke,now); + + return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.TenantId, request.SessionId), ct); } public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) { - var at = request.At ?? DateTime.UtcNow; + var now = request.At ?? DateTimeOffset.UtcNow; if (request.CurrentSessionId is null) - throw new InvalidOperationException( - "CurrentSessionId must be provided for logout-all operation."); + throw new InvalidOperationException("CurrentSessionId must be provided for logout-all operation."); var currentSessionId = request.CurrentSessionId.Value; - var validation = await _sessions.ValidateSessionAsync( - request.TenantId, - currentSessionId, - at); + var validation = await _queries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = request.TenantId, + SessionId = currentSessionId, + Now = now + }, + ct); - if (validation.IsValid || - validation.Session is null) + if (!validation.IsValid || validation.Session is null) throw new InvalidOperationException("Current session is not valid."); var userId = validation.Session.UserId; - ChainId? currentChainId = null; + ChainId? exceptChainId = null; if (request.ExceptCurrent) { - if (request.CurrentSessionId is null) - throw new InvalidOperationException("CurrentSessionId must be provided when ExceptCurrent is true."); - - currentChainId = await _sessions.ResolveChainIdAsync( + exceptChainId = await _queries.ResolveChainIdAsync( request.TenantId, - currentSessionId); + currentSessionId, + ct); - if (currentChainId is null) + if (exceptChainId is null) throw new InvalidOperationException("Current session chain could not be resolved."); } - await _sessions.RevokeAllChainsAsync(request.TenantId, userId, exceptChainId: currentChainId, at); + var authContext = AuthContext.System( + request.TenantId, + AuthOperation.Revoke, + now); + + await _orchestrator.ExecuteAsync( + authContext, + new RevokeAllChainsCommand( + userId, + exceptChainId), + ct); } public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) @@ -135,9 +178,53 @@ public Task ReauthenticateAsync(ReauthRequest request, Cancellatio throw new NotImplementedException(); } - public Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default) + public async Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default) { - throw new NotImplementedException(); + var now = DateTimeOffset.UtcNow; + var resolved = await _refreshTokens.ResolveAsync(request.TenantId, request.RefreshToken, now, ct); + + if (resolved is null) + return SessionRefreshResult.Invalid(); + + if (!resolved.IsValid) + { + // TODO: Add reuse detection handling here + //if (resolved.IsReuseDetected) + //{ + // await _sessions.RevokeChainAsync( + // tenantId, + // resolved.Chain!.ChainId, + // now); + //} + + //return SessionRefreshResult.ReauthRequired(); + } + + var session = resolved.Session; + + var rotationContext = new SessionRotationContext + { + TenantId = request.TenantId, + CurrentSessionId = session.SessionId, + UserId = session.UserId, + Now = now + }; + + var authContext = AuthContext.ForAuthenticatedUser(request.TenantId, AuthOperation.Refresh, now, DeviceContext.From(session.Device)); + + var issuedSession = await _orchestrator.ExecuteAsync(authContext, new RotateSessionCommand(rotationContext), ct); + + var tokenContext = new TokenIssuanceContext + { + TenantId = request.TenantId, + UserId = session.UserId!.ToString()!, + SessionId = issuedSession.Session.SessionId + }; + + var accessToken = await _tokens.IssueAccessTokenAsync(tokenContext, ct); + var refreshToken = await _tokens.IssueRefreshTokenAsync(tokenContext, ct); + + return SessionRefreshResult.Success(accessToken, refreshToken); } public Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs index 43cdf356..4d0e0df8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs @@ -2,22 +2,25 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; namespace CodeBeam.UltimateAuth.Server.Services { internal sealed class UAuthSessionService : IUAuthSessionService { private readonly ISessionOrchestrator _orchestrator; + private readonly ISessionQueryService _sessionQueryService; - public UAuthSessionService(ISessionOrchestrator orchestrator) + public UAuthSessionService(ISessionOrchestrator orchestrator, ISessionQueryService sessionQueryService) { _orchestrator = orchestrator; + _sessionQueryService = sessionQueryService; } public Task> ValidateSessionAsync( string? tenantId, AuthSessionId sessionId, - DateTime now) + DateTimeOffset now) { var context = new SessionValidationContext() { @@ -25,100 +28,84 @@ public Task> ValidateSessionAsync( Now = now }; - return _orchestrator.ValidateSessionAsync(context); + return _sessionQueryService.ValidateSessionAsync(context); } public Task>> GetChainsAsync( string? tenantId, TUserId userId) - => _orchestrator.GetChainsAsync( + => _sessionQueryService.GetChainsByUserAsync( tenantId, userId); public Task>> GetSessionsAsync( string? tenantId, ChainId chainId) - => _orchestrator.GetSessionsAsync( + => _sessionQueryService.GetSessionsByChainAsync( tenantId, chainId); public Task?> GetSessionAsync( string? tenantId, AuthSessionId sessionId) - => _orchestrator.GetSessionAsync( + => _sessionQueryService.GetSessionAsync( tenantId, sessionId); - public Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTime at) - => _orchestrator.RevokeSessionAsync( - tenantId, - sessionId, - at); + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) + { + var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); + var command = new RevokeSessionCommand(tenantId,sessionId); - public Task ResolveChainIdAsync( - string? tenantId, - AuthSessionId sessionId) - => _orchestrator.ResolveChainIdAsync(tenantId, sessionId); + return _orchestrator.ExecuteAsync(authContext, command); + } - public Task RevokeAllChainsAsync( - string? tenantId, - TUserId userId, - ChainId? exceptChainId, - DateTime at) - => _orchestrator.RevokeAllChainsAsync( - tenantId, - userId, - exceptChainId, - at); + public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId) + => _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); - public Task RevokeChainAsync( - string? tenantId, - ChainId chainId, - DateTime at) - => _orchestrator.RevokeChainAsync( - tenantId, - chainId, - at); + public Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at) + { + var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); + var command = new RevokeAllChainsCommand(userId, exceptChainId); - public Task RevokeRootAsync( - string? tenantId, - TUserId userId, - DateTime at) - => _orchestrator.RevokeRootAsync( - tenantId, - userId, - at); + return _orchestrator.ExecuteAsync(authContext, command); + } - public Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) + public Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at) { - // TODO: Implement this method - throw new NotImplementedException(); + var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); + var command = new RevokeChainCommand(chainId); + + return _orchestrator.ExecuteAsync(authContext, command); } - public Task> IssueSessionAfterAuthenticationAsync( - string? tenantId, - AuthenticatedSessionContext context, - CancellationToken cancellationToken = default) + public Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at) { - if (context.UserId is null) - throw new InvalidOperationException( - "Authenticated session context requires a valid user id."); + var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); + var command = new RevokeRootCommand(userId); - // Authenticated → IssueContext map - var issueContext = new AuthenticatedSessionContext - { - TenantId = tenantId, - UserId = context.UserId, - Now = context.Now, - DeviceInfo = context.DeviceInfo, - Claims = context.Claims, - ChainId = context.ChainId - }; + return _orchestrator.ExecuteAsync(authContext, command); + } + + public async Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) + { + var chainId = await _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); + + if (chainId is null) + return null; + + var sessions = await _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId.Value); + + return sessions.FirstOrDefault(s => s.SessionId == sessionId); + } + + public Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken ct = default) + { + var deviceContext = DeviceContext.From(context.DeviceInfo); + var authContext = AuthContext.ForAuthenticatedUser(tenantId, AuthOperation.Login, context.Now, deviceContext); + var command = new CreateLoginSessionCommand(context); - return _orchestrator.CreateLoginSessionAsync(issueContext); + return _orchestrator.ExecuteAsync(authContext, command, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs index bb02e0b2..5a2fdba5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs @@ -46,7 +46,7 @@ await _userStore.CreateAsync( Id = userId, Username = request.Identifier, PasswordHash = hash, - CreatedAt = DateTime.UtcNow + CreatedAt = DateTimeOffset.UtcNow }, ct); diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs rename to src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs rename to src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.Server.Users.csproj b/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.Server.Users.csproj rename to src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Extensions/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Extensions/.gitkeep rename to src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Middlewares/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Middlewares/.gitkeep rename to src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Options/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Options/.gitkeep similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Options/.gitkeep rename to src/CodeBeam.UltimateAuth.Users/Options/.gitkeep diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Services/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Services/.gitkeep similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Services/.gitkeep rename to src/CodeBeam.UltimateAuth.Users/Services/.gitkeep diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs similarity index 75% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs index 8aa7cb51..5a8de734 100644 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs +++ b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs @@ -10,7 +10,7 @@ public sealed class UserDto public bool IsActive { get; init; } public bool IsEmailConfirmed { get; init; } - public DateTime CreatedAt { get; init; } - public DateTime? LastLoginAt { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastLoginAt { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs index 9b2496ea..141d34a7 100644 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs +++ b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs @@ -9,6 +9,6 @@ public sealed class UserProfileDto public bool IsEmailConfirmed { get; init; } - public DateTime CreatedAt { get; init; } + public DateTimeOffset CreatedAt { get; init; } } } From e21f572cd9c78f36e6852990d5ab77b6722e077d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 21 Dec 2025 18:03:08 +0300 Subject: [PATCH 12/50] Create HappyPath (#6) * Create HappyPath * Add All Required Projects * Add Cookie Manager With Basics & Create Blazor Server Sample * The First Succesful Blazor Server Sample Startup * Finalize First Working Login Flow on Blazor Server --- ...Beam.UltimateAuth.Server.AspNetCore.csproj | 9 + UltimateAuth.slnx | 9 +- .../UltimateAuth.BlazorServer.slnx | 3 + .../Components/App.razor | 26 +++ .../Components/Layout/MainLayout.razor | 14 ++ .../Components/Layout/MainLayout.razor.css | 96 +++++++++ .../Components/Pages/Counter.razor | 18 ++ .../Components/Pages/Error.razor | 36 ++++ .../Components/Pages/Home.razor | 21 ++ .../Components/Pages/Home.razor.cs | 47 +++++ .../Components/Pages/Weather.razor | 63 ++++++ .../Components/Routes.razor | 6 + .../Components/_Imports.razor | 16 ++ .../UltimateAuth.BlazorServer/Program.cs | 68 +++++++ .../Properties/launchSettings.json | 38 ++++ .../UltimateAuth.BlazorServer.csproj | 23 +++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../UltimateAuth.BlazorServer/wwwroot/app.css | 62 ++++++ .../wwwroot/bootstrap/bootstrap.min.css | 7 + .../wwwroot/bootstrap/bootstrap.min.css.map | 1 + .../wwwroot/favicon.png | Bin 0 -> 1148 bytes .../Principals/IUserAuthenticator.cs | 2 +- .../Principals/IUserIdConverter.cs | 2 + .../Principals/IUserIdConverterResolver.cs | 2 +- .../Services/IUAuthFlowService.cs | 2 +- .../Abstractions/Services/IUAuthService.cs | 2 +- .../Services/IUAuthUserService.cs | 11 +- .../Abstractions/Stores/IUAuthUserStore.cs | 26 +-- .../Authority/AuthenticationContext.cs | 12 ++ .../Contracts/Session/SessionRefreshResult.cs | 23 ++- .../Token/RefreshTokenValidationResult.cs | 17 +- .../Domain/Session/ISession.cs | 1 + .../Domain/Session/UAuthSession.cs | 4 +- .../Domain/Token/SessionRefreshStatus.cs | 8 + .../Domain/Token/UAuthJwtTokenDescriptor.cs | 14 +- .../Domain/UAuthClaim.cs | 4 + .../Authority/DefaultAuthAuthority.cs | 6 + .../UAuthRefreshTokenResolver.cs | 14 +- .../UAuthUserIdConverterResolver.cs | 2 +- .../Options/UAuthTokenOptions.cs | 12 +- .../Options/UAuthTokenOptionsValidator.cs | 9 - .../Abstractions/IHttpSessionIssuer.cs | 19 ++ .../Abstractions/ISigningKeyProvider.cs | 17 ++ .../CodeBeam.UltimateAuth.Server.csproj | 2 +- .../AddUltimateAuthServerExtensions.cs | 20 ++ .../Composition/UltimateAuthServerBuilder.cs | 13 ++ .../UltimateAuthServerBuilderValidation.cs | 23 +++ .../Cookies/IUAuthSessionCookieManager.cs | 14 ++ .../Cookies/UAuthSessionCookieManager.cs | 57 ++++++ .../Diagnostics/UAuthDiagnostic.cs | 10 + .../Diagnostics/UAuthStartupDiagnostics.cs | 19 ++ .../Endpoints/DefaultLoginEndpointHandler.cs | 91 ++++++--- .../Endpoints/DefaultLogoutEndpointHandler.cs | 8 +- .../Endpoints/LoginEndpointHandlerBridge.cs | 19 ++ .../Endpoints/UAuthEndpointDefaultsMap.cs | 9 +- .../Endpoints/UAuthEndpointRegistrar.cs | 47 +++-- .../EndpointRouteBuilderExtensions.cs | 31 +++ .../UAuthApplicationBuilderExtensions.cs | 3 +- .../UAuthServerServiceCollectionExtensions.cs | 133 ++++++++++--- .../Infrastructure/BearerSessionIdResolver.cs | 2 +- .../CompositeSessionIdResolver.cs | 31 ++- .../Infrastructure/CookieSessionIdResolver.cs | 20 +- ...ceResolver.cs => DefaultDeviceResolver.cs} | 0 .../DefaultJwtTokenGenerator.cs | 71 +++++++ .../DefaultOpaqueTokenGenerator.cs | 11 ++ .../DefaultUserAuthenticator.cs | 31 +-- .../DevelopmentJwtSigningKeyProvider.cs | 32 +++ .../Infrastructure/HeaderSessionIdResolver.cs | 17 +- .../Infrastructure/HmacSha256TokenHasher.cs | 37 ++++ .../Infrastructure/IInnerSessionIdResolver.cs | 10 + .../Infrastructure/IUserAccessor.cs | 5 + .../Orchestrator/UAuthSessionQueryService.cs | 3 +- .../Infrastructure/QuerySessionIdResolver.cs | 20 +- .../Infrastructure/TokenIssuanceContext.cs | 4 +- .../Infrastructure/UAuthSessionIdResolver.cs | 44 ----- .../Infrastructure/UAuthUserAccessor.cs | 2 +- .../Infrastructure/UserAccessorBridge.cs | 23 +++ .../Issuers/UAuthSessionIssuer.cs | 50 ++++- .../Issuers/UAuthTokenIssuer.cs | 30 +-- .../SessionResolutionMiddleware.cs | 11 +- .../Middlewares/TenantMiddleware.cs | 24 +-- .../Middlewares/UserMiddleware.cs | 10 +- .../MultiTenancy/UAuthTenantResolver.cs | 5 +- .../Options/ConfigureDefaults.cs | 122 ++++++++++++ .../Options/UAuthClientProfile.cs | 12 ++ .../Options/UAuthCookieOptions.cs | 18 ++ .../Options/UAuthCookiePolicyResolver.cs | 20 ++ .../Options/UAuthHubDeploymentMode.cs | 26 +++ .../Options/UAuthServerOptions.cs | 24 ++- .../Options/UAuthServerOptionsValidator.cs | 5 +- .../Services/UAuthFlowService.cs | 66 +++++-- .../Services/UAuthTokenService.cs | 2 +- .../Services/UAuthUserService.cs | 110 +++-------- .../IUAuthUserManagementService.cs | 10 +- .../Contracts}/RegisterUserRequest.cs | 2 +- ...m.UltimateAuth.Credentials.InMemory.csproj | 14 ++ .../InMemoryCredentialUser.cs | 43 ++++ .../InMemoryCredentialsSeeder.cs | 26 +++ .../InMemoryUserStore.cs | 123 ++++++++++++ .../ServiceCollectionExtensions.cs | 34 ++++ .../Argon2Options.cs | 13 ++ .../Argon2PasswordHasher.cs | 62 ++++++ ...deBeam.UltimateAuth.Security.Argon2.csproj | 19 ++ .../ServiceCollectionExtensions.cs | 19 ++ ...timateAuthServerBuilderArgon2Extensions.cs | 16 ++ ...Beam.UltimateAuth.Sessions.InMemory.csproj | 14 ++ .../IMemorySessionStoreKernel.cs | 147 ++++++++++++++ .../InMemorySessionStore.cs | 187 ++++++++++++++++++ .../InMemorySessionStoreFactory.cs | 21 ++ .../ServiceCollectionExtensions.cs | 15 ++ ...deBeam.UltimateAuth.Tokens.InMemory.csproj | 14 ++ .../InMemoryTokenStore.cs | 122 ++++++++++++ .../InMemoryTokenStoreFactory.cs | 18 ++ .../InMemoryTokenStoreKernel.cs | 77 ++++++++ .../ServiceCollectionExtensions.cs | 15 ++ 116 files changed, 2800 insertions(+), 397 deletions(-) create mode 100644 CodeBeam.UltimateAuth.Server.AspNetCore/CodeBeam.UltimateAuth.Server.AspNetCore.csproj create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer.slnx create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor.css create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Counter.razor create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Error.razor create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Weather.razor create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Routes.razor create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Program.cs create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Properties/launchSettings.json create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/appsettings.Development.json create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/appsettings.json create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/app.css create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map create mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/favicon.png create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/UAuthClaim.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthSessionCookieManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{UAuthDeviceResolver.cs => DefaultDeviceResolver.cs} (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultJwtTokenGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultOpaqueTokenGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/ConfigureDefaults.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthClientProfile.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthHubDeploymentMode.cs rename src/{CodeBeam.UltimateAuth.Core/Contracts/User => CodeBeam.UltimateAuth.Users/Contracts}/RegisterUserRequest.cs (93%) create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs create mode 100644 src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs create mode 100644 src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs create mode 100644 src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj create mode 100644 src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs create mode 100644 src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreFactory.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreKernel.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs diff --git a/CodeBeam.UltimateAuth.Server.AspNetCore/CodeBeam.UltimateAuth.Server.AspNetCore.csproj b/CodeBeam.UltimateAuth.Server.AspNetCore/CodeBeam.UltimateAuth.Server.AspNetCore.csproj new file mode 100644 index 00000000..983bc8d0 --- /dev/null +++ b/CodeBeam.UltimateAuth.Server.AspNetCore/CodeBeam.UltimateAuth.Server.AspNetCore.csproj @@ -0,0 +1,9 @@ + + + + net8.0;net9.0;net10.0 + + + + + diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 8f20ba91..3f9c79cd 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,10 +1,17 @@ + + + - + + + + + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer.slnx b/samples/blazor-server/UltimateAuth.BlazorServer.slnx new file mode 100644 index 00000000..27a855de --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer.slnx @@ -0,0 +1,3 @@ + + + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor new file mode 100644 index 00000000..22f3633f --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor new file mode 100644 index 00000000..e13fedec --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor @@ -0,0 +1,14 @@ +@inherits LayoutComponentBase + + + + + + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor.css b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000..038baf17 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor.css @@ -0,0 +1,96 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#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/UltimateAuth.BlazorServer/Components/Pages/Counter.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Counter.razor new file mode 100644 index 00000000..ef23cb31 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Counter.razor @@ -0,0 +1,18 @@ +@page "/counter" + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Error.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Error.razor new file mode 100644 index 00000000..576cc2d2 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.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/UltimateAuth.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor new file mode 100644 index 00000000..0287b897 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor @@ -0,0 +1,21 @@ +@page "/" +@inject IUAuthFlowService FlowService +@inject ISnackbar Snackbar +@inject IHttpClientFactory Http + +
+ +
+ Welcome to UltimateAuth! + + + + + + + + + Login + +
+
diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs new file mode 100644 index 00000000..64730ee2 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using MudBlazor; + +namespace UltimateAuth.BlazorServer.Components.Pages +{ + public partial class Home + { + private string? _username; + private string? _password; + + private async Task LoginAsync() + { + + try + { + //var result = await FlowService.LoginAsync(new LoginRequest + //{ + // Identifier = _username!, + // Secret = _password! + //}); + var client = Http.CreateClient(); + var result = await client.PostAsJsonAsync( + "https://localhost:7213/auth/login", + new LoginRequest + { + Identifier = _username!, + Secret = _password! + }); + + + if (!result.IsSuccessStatusCode) + { + Snackbar.Add("Login failed.", Severity.Info); + return; + } + + Snackbar.Add("Successfully logged in!", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add(ex.ToString(), Severity.Error); + } + } + } +} diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Weather.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Weather.razor new file mode 100644 index 00000000..8eca4cc4 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Weather.razor @@ -0,0 +1,63 @@ +@page "/weather" + +Weather + +

Weather

+ +

This component demonstrates showing data.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + // Simulate asynchronous loading to demonstrate a loading indicator + await Task.Delay(500); + + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).ToArray(); + } + + private class WeatherForecast + { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Routes.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Routes.razor new file mode 100644 index 00000000..f756e19d --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor new file mode 100644 index 00000000..2b16dea5 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor @@ -0,0 +1,16 @@ +@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 UltimateAuth.BlazorServer +@using UltimateAuth.BlazorServer.Components + +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Domain + +@using MudBlazor +@using MudExtensions diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs b/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs new file mode 100644 index 00000000..0c127de8 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs @@ -0,0 +1,68 @@ +using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Security.Argon2; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Sessions.InMemory; +using CodeBeam.UltimateAuth.Tokens.InMemory; +using Microsoft.AspNetCore.Components; +using MudBlazor.Services; +using MudExtensions.Services; +using UltimateAuth.BlazorServer.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddMudServices(); +builder.Services.AddMudExtensions(); + +builder.Services.AddAuthentication(); +builder.Services.AddAuthorization(); + +builder.Services.AddHttpContextAccessor(); + + +builder.Services.AddUltimateAuthServer() + .AddInMemoryCredentials() + .AddUltimateAuthInMemorySessions() + .AddUltimateAuthInMemoryTokens() + .AddUltimateAuthArgon2(); + +builder.Services.AddHttpClient("AuthApi", client => +{ + client.BaseAddress = new Uri("https://localhost:7213"); +}) +.ConfigurePrimaryHttpMessageHandler(() => +{ + return new HttpClientHandler + { + UseCookies = true + }; +}); + + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseStaticFiles(); + +app.UseUltimateAuthServer(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapUAuthEndpoints(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Properties/launchSettings.json b/samples/blazor-server/UltimateAuth.BlazorServer/Properties/launchSettings.json new file mode 100644 index 00000000..e5dc5589 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.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/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj b/samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj new file mode 100644 index 00000000..fbfb55f2 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/appsettings.Development.json b/samples/blazor-server/UltimateAuth.BlazorServer/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/appsettings.json b/samples/blazor-server/UltimateAuth.BlazorServer/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/app.css b/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/app.css new file mode 100644 index 00000000..8110a42a --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/app.css @@ -0,0 +1,62 @@ +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-page { + height: 100vh; + width: 100vw; +} + +.uauth-stack { + height: 60vh; + width: 30vw; + min-width: 300px; +} diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css b/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css new file mode 100644 index 00000000..02ae65b5 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.1.0 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors + * Copyright 2011-2021 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--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-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-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-rgb:33,37,41;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",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-bg:#fff}*,::after,::before{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:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],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}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,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]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button: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:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-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}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);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}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(var(--bs-gutter-y) * -1);margin-right:calc(var(--bs-gutter-x) * -.5);margin-left:calc(var(--bs-gutter-x) * -.5)}.row>*{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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:last-child)>:last-child>*{border-bottom-color:currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-striped>tbody>tr:nth-of-type(odd){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:calc(3 / 4 * 100%)}.ratio-16x9{--bs-aspect-ratio:calc(9 / 16 * 100%)}.ratio-21x9{--bs-aspect-ratio:calc(9 / 21 * 100%)}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display: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}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!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}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!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}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.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-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}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!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}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.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-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}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!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}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.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-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}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!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}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.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-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}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!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}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.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-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}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!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}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!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-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.min.css.map */ \ No newline at end of file diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map b/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map new file mode 100644 index 00000000..afcd9e33 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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","sourcesContent":["/*!\n * Bootstrap v5.1.0 (https://getbootstrap.com/)\n * Copyright 2011-2021 The Bootstrap Authors\n * Copyright 2011-2021 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n\n// scss-docs-start import-stack\n// Configuration\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"utilities\";\n\n// Layout & components\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"containers\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"accordion\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"alert\";\n@import \"progress\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"offcanvas\";\n@import \"placeholders\";\n\n// Helpers\n@import \"helpers\";\n\n// Utilities\n@import \"utilities/api\";\n// scss-docs-end import-stack\n",":root {\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 --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$variable-prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$variable-prefix}#{$color}-rgb: #{$value};\n }\n\n --#{$variable-prefix}white-rgb: #{to-rgb($white)};\n --#{$variable-prefix}black-rgb: #{to-rgb($black)};\n --#{$variable-prefix}body-rgb: #{to-rgb($body-color)};\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 --#{$variable-prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$variable-prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$variable-prefix}gradient: #{$gradient};\n\n // Root and body\n // stylelint-disable custom-property-empty-line-before\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$variable-prefix}root-font-size: #{$font-size-root};\n }\n --#{$variable-prefix}body-font-family: #{$font-family-base};\n --#{$variable-prefix}body-font-size: #{$font-size-base};\n --#{$variable-prefix}body-font-weight: #{$font-weight-base};\n --#{$variable-prefix}body-line-height: #{$line-height-base};\n --#{$variable-prefix}body-color: #{$body-color};\n @if $body-text-align != null {\n --#{$variable-prefix}body-text-align: #{$body-text-align};\n }\n --#{$variable-prefix}body-bg: #{$body-bg};\n // scss-docs-end root-body-variables\n // stylelint-enable custom-property-empty-line-before\n}\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 font-size: var(--#{$variable-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(--#{$variable-prefix}body-font-family);\n @include font-size(var(--#{$variable-prefix}body-font-size));\n font-weight: var(--#{$variable-prefix}body-font-weight);\n line-height: var(--#{$variable-prefix}body-line-height);\n color: var(--#{$variable-prefix}body-color);\n text-align: var(--#{$variable-prefix}body-text-align);\n background-color: var(--#{$variable-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// 2. Set correct height and prevent the `size` attribute to make the `hr` look like an input field\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n background-color: currentColor;\n border: 0;\n opacity: $hr-opacity;\n}\n\nhr:not([size]) {\n height: $hr-height; // 2\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: $headings-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. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\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 background-color: $mark-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: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\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 direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\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: $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-`

public interface IUserIdConverter { diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs index 6258bb6f..fc642d37 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs @@ -18,6 +18,6 @@ public interface IUserIdConverterResolver /// /// Thrown if no converter has been registered for the requested user ID type. /// - IUserIdConverter GetConverter(); + IUserIdConverter GetConverter(string? purpose = null); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs index 530bacd8..748032ad 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// Handles authentication flows such as login, /// logout, session refresh and reauthentication. ///
- public interface IUAuthFlowService + public interface IUAuthFlowService { Task LoginAsync(LoginRequest request, CancellationToken ct = default); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs index 3e3e0ac0..c78e06af 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs @@ -7,7 +7,7 @@ ///
public interface IUAuthService { - IUAuthFlowService Flow { get; } + IUAuthFlowService Flow { get; } IUAuthSessionService Sessions { get; } IUAuthTokenService Tokens { get; } IUAuthUserService Users { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs index ee74667a..af6e3bac 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs @@ -3,18 +3,13 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions { /// - /// Minimal user operations required for authentication. - /// Does NOT include role or permission management. + /// Defines the minimal user authentication contract expected by UltimateAuth. + /// This service does not manage sessions, tokens, or transport concerns. /// For user management, CodeBeam.UltimateAuth.Users package is recommended. /// public interface IUAuthUserService { - Task RegisterAsync(RegisterUserRequest request, CancellationToken cancellationToken = default); - - Task DeleteAsync(TUserId userId, CancellationToken cancellationToken = default); - - Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); - Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); + Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs index 53f78c59..e8a181ba 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs @@ -12,9 +12,7 @@ public interface IUAuthUserStore { Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken token = default); - Task?> FindByUsernameAsync(string? tenantId, - string username, - CancellationToken ct = default); + Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default); /// /// Retrieves a user by a login credential such as username or email. @@ -22,7 +20,7 @@ public interface IUAuthUserStore /// /// The login value used to locate the user. /// The user instance or null if not found. - Task?> FindByLoginAsync(string login); + Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken token = default); /// /// Returns the password hash for the specified user, if the user participates @@ -31,7 +29,7 @@ public interface IUAuthUserStore /// /// The user identifier. /// The password hash or null. - Task GetPasswordHashAsync(TUserId userId); + Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken token = default); /// /// Updates the password hash for the specified user. This method is invoked by @@ -39,7 +37,7 @@ public interface IUAuthUserStore /// /// The user identifier. /// The new password hash value. - Task SetPasswordHashAsync(TUserId userId, string passwordHash); + Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default); /// /// Retrieves the security version associated with the user. @@ -48,25 +46,13 @@ public interface IUAuthUserStore /// /// The user identifier. /// The current security version. - Task GetSecurityVersionAsync(TUserId userId); + Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default); /// /// Increments the user's security version, invalidating all existing sessions. /// This is typically called after sensitive security events occur. /// /// The user identifier. - Task IncrementSecurityVersionAsync(TUserId userId); - - Task ExistsByUsernameAsync( - string username, - CancellationToken ct = default); - - Task CreateAsync( - UserRecord user, - CancellationToken ct = default); - - Task DeleteAsync( - TUserId userId, - CancellationToken ct = default); + Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs new file mode 100644 index 00000000..76145d2e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record AuthenticationContext + { + public string? TenantId { get; init; } + public string Identifier { get; init; } = default!; + public string Secret { get; init; } = default!; + public AuthOperation Operation { get; init; } // Login, Reauth, Validate + public DeviceContext? Device { get; init; } + public string CredentialType { get; init; } = "password"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs index f3f7d934..69ad1be6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -1,21 +1,36 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed record SessionRefreshResult { - public AccessToken AccessToken { get; init; } = default!; + public SessionRefreshStatus Status { get; init; } + + public AccessToken? AccessToken { get; init; } + public RefreshToken? RefreshToken { get; init; } - public bool IsValid => AccessToken is not null; + public bool IsSuccess => Status == SessionRefreshStatus.Success; private SessionRefreshResult() { } - public static SessionRefreshResult Success(AccessToken accessToken, RefreshToken? refreshToken) + public static SessionRefreshResult Success( + AccessToken accessToken, + RefreshToken? refreshToken) => new() { + Status = SessionRefreshStatus.Success, AccessToken = accessToken, RefreshToken = refreshToken }; + public static SessionRefreshResult ReauthRequired() + => new() + { + Status = SessionRefreshStatus.ReauthRequired + }; + + // TODO: ? public static SessionRefreshResult Invalid() => new(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs index 68641efb..cabbb2a8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs @@ -6,16 +6,30 @@ public sealed record RefreshTokenValidationResult { public bool IsValid { get; init; } + public bool IsReuseDetected { get; init; } + public TUserId? UserId { get; init; } public AuthSessionId? SessionId { get; init; } private RefreshTokenValidationResult() { } + // ---------------------------- + // FACTORIES + // ---------------------------- + public static RefreshTokenValidationResult Invalid() => new() { - IsValid = false + IsValid = false, + IsReuseDetected = false + }; + + public static RefreshTokenValidationResult ReuseDetected() + => new() + { + IsValid = false, + IsReuseDetected = true }; public static RefreshTokenValidationResult Valid( @@ -24,6 +38,7 @@ public static RefreshTokenValidationResult Valid( => new() { IsValid = true, + IsReuseDetected = false, UserId = userId, SessionId = sessionId }; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs index 1fcb8458..869a0d93 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -78,6 +78,7 @@ public interface ISession bool ShouldUpdateLastSeen(DateTimeOffset now); ISession Touch(DateTimeOffset now); + ISession Revoke(DateTimeOffset at); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index c3c0d42c..975d4d3d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -126,11 +126,11 @@ public ISession Touch(DateTimeOffset at) ); } - public UAuthSession Revoke(DateTimeOffset at) + public ISession Revoke(DateTimeOffset at) { if (IsRevoked) return this; - return new( + return new UAuthSession( SessionId, TenantId, UserId, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs new file mode 100644 index 00000000..176bab4d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public enum SessionRefreshStatus + { + Success, + ReauthRequired + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs index c272e507..3961fbdb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs @@ -7,18 +7,18 @@ namespace CodeBeam.UltimateAuth.Core.Domain ///
public sealed class UAuthJwtTokenDescriptor { - public required ClaimsIdentity Subject { get; init; } + public required string Subject { get; init; } public required string Issuer { get; init; } public required string Audience { get; init; } - public required DateTimeOffset Expires { get; init; } + public required DateTimeOffset IssuedAt { get; init; } + public required DateTimeOffset ExpiresAt { get; init; } + public string? TenantId { get; init; } - /// - /// Signing key material (symmetric or asymmetric). - /// Interpretation is up to the generator implementation. - /// - public required object SigningKey { get; init; } + public IReadOnlyDictionary? Claims { get; init; } + + public string? KeyId { get; init; } // kid } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/UAuthClaim.cs b/src/CodeBeam.UltimateAuth.Core/Domain/UAuthClaim.cs new file mode 100644 index 00000000..c0b05117 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/UAuthClaim.cs @@ -0,0 +1,4 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public sealed record UAuthClaim(string Type, string Value); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs index 4b5ca72b..830ebfe4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs @@ -8,6 +8,12 @@ public sealed class DefaultAuthAuthority : IAuthAuthority private readonly IEnumerable _invariants; private readonly IEnumerable _policies; + public DefaultAuthAuthority(IEnumerable invariants, IEnumerable policies) + { + _invariants = invariants ?? Array.Empty(); + _policies = policies ?? Array.Empty(); + } + public AuthorizationResult Decide(AuthContext context) { // 1. Invariants diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs index cbbbfc2f..0be3625f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs @@ -3,28 +3,20 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure { - internal sealed class StoreRefreshTokenResolver - : IRefreshTokenResolver + public sealed class UAuthRefreshTokenResolver : IRefreshTokenResolver { private readonly ISessionStoreFactory _sessionStoreFactory; private readonly ITokenStoreFactory _tokenStoreFactory; private readonly ITokenHasher _hasher; - public StoreRefreshTokenResolver( - ISessionStoreFactory sessionStoreFactory, - ITokenStoreFactory tokenStoreFactory, - ITokenHasher hasher) + public UAuthRefreshTokenResolver(ISessionStoreFactory sessionStoreFactory, ITokenStoreFactory tokenStoreFactory, ITokenHasher hasher) { _sessionStoreFactory = sessionStoreFactory; _tokenStoreFactory = tokenStoreFactory; _hasher = hasher; } - public async Task?> ResolveAsync( - string? tenantId, - string refreshToken, - DateTimeOffset now, - CancellationToken ct = default) + public async Task?> ResolveAsync(string? tenantId, string refreshToken, DateTimeOffset now, CancellationToken ct = default) { var tokenHash = _hasher.Hash(refreshToken); diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs index 8ac22a10..0d7a489e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs @@ -34,7 +34,7 @@ public UAuthUserIdConverterResolver(IServiceProvider sp) ///
/// The user id type for which to resolve a converter. /// An instance. - public IUserIdConverter GetConverter() + public IUserIdConverter GetConverter(string? provider) { var converter = _sp.GetService>(); if (converter != null) diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs index 764eaf2e..fe67c8d6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -36,12 +36,6 @@ public sealed class UAuthTokenOptions ///
public int OpaqueIdBytes { get; set; } = 32; - /// - /// Symmetric key used to sign JWT access tokens. - /// Must be long and cryptographically strong. - /// - public string SigningKey { get; set; } = string.Empty!; - /// /// Value assigned to the JWT "iss" (issuer) claim. /// Identifies the authority that issued the token. @@ -59,5 +53,11 @@ public sealed class UAuthTokenOptions /// 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; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs index 084c4da0..d5baf784 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs @@ -22,15 +22,6 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) if (options.IssueJwt) { - if (string.IsNullOrWhiteSpace(options.SigningKey)) - { - errors.Add("Token.SigningKey must not be empty when IssueJwt = true."); - } - else if (options.SigningKey.Length < 32) // 256-bit minimum - { - errors.Add("Token.SigningKey must be at least 32 characters long (256-bit entropy)."); - } - if (string.IsNullOrWhiteSpace(options.Issuer)) // TODO: Min 3 chars errors.Add("Token.Issuer must not be empty when IssueJwt = true."); diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs new file mode 100644 index 00000000..962be542 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Abstractions +{ + /// + /// HTTP-aware session issuer used by UltimateAuth server components. + /// Extends the core ISessionIssuer contract with HttpContext-bound + /// operations required for cookie-based session binding. + /// + public interface IHttpSessionIssuer : ISessionIssuer + { + Task> IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + + Task> RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs new file mode 100644 index 00000000..74ae5b49 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs @@ -0,0 +1,17 @@ +using Microsoft.IdentityModel.Tokens; + +namespace CodeBeam.UltimateAuth.Server.Abstractions +{ + public interface IJwtSigningKeyProvider + { + JwtSigningKey Resolve(string? keyId); + } + + 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/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index 957c7210..8846acee 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -10,7 +10,7 @@ - + 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..5259492a --- /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..8424440d --- /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/Cookies/IUAuthSessionCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthSessionCookieManager.cs new file mode 100644 index 00000000..359b38d7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthSessionCookieManager.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Cookies; + +/// +/// Responsible for issuing, reading and revoking +/// UltimateAuth session cookies. +/// +public interface IUAuthSessionCookieManager +{ + void Issue(HttpContext context, string sessionId); + bool TryRead(HttpContext context, out string sessionId); + void Revoke(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs new file mode 100644 index 00000000..f723de85 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs @@ -0,0 +1,57 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Cookies; + +internal sealed class UAuthSessionCookieManager : IUAuthSessionCookieManager +{ + private readonly UAuthServerOptions _options; + + public UAuthSessionCookieManager(IOptions options) + { + _options = options.Value; + } + + public void Issue(HttpContext context, string sessionId) + { + var cookieOptions = BuildCookieOptions(context); + context.Response.Cookies.Append(_options.Cookie.Name, sessionId, cookieOptions); + } + + public bool TryRead(HttpContext context, out string sessionId) + { + return context.Request.Cookies.TryGetValue(_options.Cookie.Name, out sessionId!); + } + + public void Revoke(HttpContext context) + { + context.Response.Cookies.Delete(_options.Cookie.Name, BuildCookieOptions(context)); + } + + private CookieOptions BuildCookieOptions(HttpContext context) + { + return new CookieOptions + { + HttpOnly = _options.Cookie.HttpOnly, + Secure = _options.Cookie.SecurePolicy == CookieSecurePolicy.Always, + SameSite = ResolveSameSite(), + Path = "/" + }; + } + + private SameSiteMode ResolveSameSite() + { + if (_options.Cookie.SameSiteOverride is not null) + return _options.Cookie.SameSiteOverride.Value; + + return _options.HubDeploymentMode switch + { + UAuthHubDeploymentMode.Embedded => SameSiteMode.Strict, + UAuthHubDeploymentMode.Integrated => SameSiteMode.Lax, + UAuthHubDeploymentMode.External => SameSiteMode.None, + _ => SameSiteMode.Lax + }; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs new file mode 100644 index 00000000..26bdc224 --- /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..d9e1c6a9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Diagnostics; + +internal static class UAuthStartupDiagnostics +{ + // TODO: Add startup log + public static IEnumerable Analyze(UAuthServerOptions options) + { + if (options.HubDeploymentMode == UAuthHubDeploymentMode.External && options.Cookie.SecurePolicy != CookieSecurePolicy.Always) + { + yield return new UAuthDiagnostic( + "UAUTH001", + "External UAuthHub without Secure cookies is unsafe. This should only be used for development or testing.", + UAuthDiagnosticSeverity.Warning); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index 56c1eaca..2b28c2b1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.MultiTenancy; @@ -9,30 +10,43 @@ public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler { - private readonly IUAuthFlowService _flow; + private readonly IUAuthFlowService _flow; private readonly IDeviceResolver _deviceResolver; private readonly ITenantResolver _tenantResolver; private readonly IClock _clock; + private readonly IUAuthSessionCookieManager _cookieManager; public DefaultLoginEndpointHandler( - IUAuthFlowService flow, + IUAuthFlowService flow, IDeviceResolver deviceResolver, ITenantResolver tenantResolver, - IClock clock) + IClock clock, + IUAuthSessionCookieManager cookieManager) { _flow = flow; _deviceResolver = deviceResolver; _tenantResolver = tenantResolver; _clock = clock; + _cookieManager = cookieManager; } public async Task LoginAsync(HttpContext ctx) { - var request = await ctx.Request.ReadFromJsonAsync(); - if (request is null) - return Results.BadRequest("Invalid login request."); + if (!ctx.Request.HasFormContentType) + return Results.BadRequest("Invalid content type."); + + var form = await ctx.Request.ReadFormAsync(); + + var request = new LoginRequest + { + Identifier = form["Identifier"], + Secret = form["Secret"] + }; + + if (string.IsNullOrWhiteSpace(request.Identifier) || + string.IsNullOrWhiteSpace(request.Secret)) + return Results.Redirect("/login?error=invalid"); - // Middleware should have already resolved the tenant var tenantCtx = ctx.GetTenantContext(); var flowRequest = request with @@ -44,23 +58,54 @@ public async Task LoginAsync(HttpContext ctx) var result = await _flow.LoginAsync(flowRequest, ctx.RequestAborted); - return result.Status switch - { - LoginStatus.Success => Results.Ok(new LoginResponse - { - SessionId = result.SessionId, - AccessToken = result.AccessToken, - RefreshToken = result.RefreshToken - }), - - LoginStatus.RequiresContinuation => Results.Ok(new LoginResponse - { - Continuation = result.Continuation - }), + if (!result.IsSuccess) + return Results.Redirect("/login?error=invalid"); - LoginStatus.Failed => Results.Unauthorized(), + _cookieManager.Issue(ctx, result.SessionId!.Value); - _ => Results.StatusCode(StatusCodes.Status500InternalServerError) - }; + return Results.Redirect("/"); } + + //public async Task LoginAsync(HttpContext ctx) + //{ + // var request = await ctx.Request.ReadFromJsonAsync(); + // if (request is null) + // return Results.BadRequest("Invalid login request."); + + // // Middleware should have already resolved the tenant + // var tenantCtx = ctx.GetTenantContext(); + + // var flowRequest = request with + // { + // TenantId = tenantCtx.TenantId, + // At = _clock.UtcNow, + // DeviceInfo = _deviceResolver.Resolve(ctx) + // }; + + // var result = await _flow.LoginAsync(flowRequest, ctx.RequestAborted); + + // if (result.IsSuccess) + // { + // _cookieManager.Issue(ctx, result.SessionId.Value); + // } + + // return result.Status switch + // { + // LoginStatus.Success => Results.Ok(new LoginResponse + // { + // SessionId = result.SessionId, + // AccessToken = result.AccessToken, + // RefreshToken = result.RefreshToken + // }), + + // LoginStatus.RequiresContinuation => Results.Ok(new LoginResponse + // { + // Continuation = result.Continuation + // }), + + // LoginStatus.Failed => Results.Unauthorized(), + + // _ => Results.StatusCode(StatusCodes.Status500InternalServerError) + // }; + //} } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs index 8a13f4f2..1084726b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; @@ -8,13 +9,15 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints { public sealed class DefaultLogoutEndpointHandler : ILogoutEndpointHandler { - private readonly IUAuthFlowService _flow; + private readonly IUAuthFlowService _flow; private readonly IClock _clock; + private readonly IUAuthSessionCookieManager _cookies; - public DefaultLogoutEndpointHandler(IUAuthFlowService flow, IClock clock) + public DefaultLogoutEndpointHandler(IUAuthFlowService flow, IClock clock, IUAuthSessionCookieManager cookieManager) { _flow = flow; _clock = clock; + _cookies = cookieManager; } public async Task LogoutAsync(HttpContext ctx) @@ -33,6 +36,7 @@ public async Task LogoutAsync(HttpContext ctx) }; await _flow.LogoutAsync(request, ctx.RequestAborted); + _cookies.Revoke(ctx); return Results.Ok(new LogoutResponse { diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs new file mode 100644 index 00000000..48c1d39b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + internal sealed class LoginEndpointHandlerBridge : ILoginEndpointHandler + { + private readonly DefaultLoginEndpointHandler _inner; + + public LoginEndpointHandlerBridge(DefaultLoginEndpointHandler inner) + { + _inner = inner; + } + + public Task LoginAsync(HttpContext ctx) + => _inner.LoginAsync(ctx); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs index 0a7b6ffe..94c2cf9f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs @@ -9,8 +9,15 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints ///
internal static class UAuthEndpointDefaultsMap { - public static UAuthEndpointDefaults ForMode(UAuthMode mode) + public static UAuthEndpointDefaults ForMode(UAuthMode? mode) { + if (!mode.HasValue) + { + throw new InvalidOperationException( + "UAuthMode must be resolved before endpoint mapping. " + + "Ensure ClientProfile defaults are applied."); + } + return mode switch { UAuthMode.PureOpaque => new UAuthEndpointDefaults diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 5169cd52..6e9c0cd0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; namespace CodeBeam.UltimateAuth.Server.Endpoints @@ -10,6 +11,7 @@ public interface IAuthEndpointRegistrar void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options); } + // TODO: Add Scalar/Swagger integration public class UAuthEndpointRegistrar : IAuthEndpointRegistrar { public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) @@ -31,28 +33,31 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { var pkce = group.MapGroup("/pkce"); - pkce.MapPost("/create", async (IPkceEndpointHandler h, HttpContext ctx) - => await h.CreateAsync(ctx)); + pkce.MapPost("/create", + async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) + => await h.CreateAsync(ctx)); - pkce.MapPost("/verify", async (IPkceEndpointHandler h, HttpContext ctx) - => await h.VerifyAsync(ctx)); + pkce.MapPost("/verify", + async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) + => await h.VerifyAsync(ctx)); - pkce.MapPost("/consume", async (IPkceEndpointHandler h, HttpContext ctx) - => await h.ConsumeAsync(ctx)); + pkce.MapPost("/consume", + async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) + => await h.ConsumeAsync(ctx)); } if (EndpointEnablement.Resolve(options.EnableLoginEndpoints, defaults.Login)) { - group.MapPost("/login", async (ILoginEndpointHandler h, HttpContext ctx) + group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) => await h.LoginAsync(ctx)); - group.MapPost("/logout", async (ILogoutEndpointHandler h, HttpContext ctx) + group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) => await h.LogoutAsync(ctx)); - group.MapPost("/refresh-session", async (ISessionRefreshEndpointHandler h, HttpContext ctx) + group.MapPost("/refresh-session", async ([FromServices] ISessionRefreshEndpointHandler h, HttpContext ctx) => await h.RefreshSessionAsync(ctx)); - group.MapPost("/reauth", async (IReauthEndpointHandler h, HttpContext ctx) + group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) => await h.ReauthAsync(ctx)); } @@ -60,16 +65,16 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { var token = group.MapGroup(""); - token.MapPost("/token", async (ITokenEndpointHandler h, HttpContext ctx) + token.MapPost("/token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) => await h.GetTokenAsync(ctx)); - token.MapPost("/refresh-token", async (ITokenEndpointHandler h, HttpContext ctx) + token.MapPost("/refresh-token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) => await h.RefreshTokenAsync(ctx)); - token.MapPost("/introspect", async (ITokenEndpointHandler h, HttpContext ctx) + token.MapPost("/introspect", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) => await h.IntrospectAsync(ctx)); - token.MapPost("/revoke", async (ITokenEndpointHandler h, HttpContext ctx) + token.MapPost("/revoke", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) => await h.RevokeAsync(ctx)); } @@ -77,16 +82,16 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { var session = group.MapGroup("/session"); - session.MapGet("/current", async (ISessionManagementHandler h, HttpContext ctx) + session.MapGet("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) => await h.GetCurrentSessionAsync(ctx)); - session.MapGet("/list", async (ISessionManagementHandler h, HttpContext ctx) + session.MapGet("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) => await h.GetAllSessionsAsync(ctx)); - session.MapPost("/revoke/{sessionId}", async (ISessionManagementHandler h, string sessionId, HttpContext ctx) + session.MapPost("/revoke/{sessionId}", async ([FromServices] ISessionManagementHandler h, string sessionId, HttpContext ctx) => await h.RevokeSessionAsync(sessionId, ctx)); - session.MapPost("/revoke-all", async (ISessionManagementHandler h, HttpContext ctx) + session.MapPost("/revoke-all", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) => await h.RevokeAllAsync(ctx)); } @@ -94,13 +99,13 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { var user = group.MapGroup(""); - user.MapGet("/userinfo", async (IUserInfoEndpointHandler h, HttpContext ctx) + user.MapGet("/userinfo", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) => await h.GetUserInfoAsync(ctx)); - user.MapGet("/permissions", async (IUserInfoEndpointHandler h, HttpContext ctx) + user.MapGet("/permissions", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) => await h.GetPermissionsAsync(ctx)); - user.MapPost("/permissions/check", async (IUserInfoEndpointHandler h, HttpContext ctx) + user.MapPost("/permissions/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) => await h.CheckPermissionAsync(ctx)); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs new file mode 100644 index 00000000..a501a1b4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,31 @@ +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 MapUAuthEndpoints(this IEndpointRouteBuilder endpoints) + { + using var scope = endpoints.ServiceProvider.CreateScope(); + + var registrar = scope.ServiceProvider + .GetRequiredService(); + + var options = scope.ServiceProvider + .GetRequiredService>() + .Value; + + // Root group ("/") + var rootGroup = endpoints.MapGroup(""); + + registrar.MapEndpoints(rootGroup, options); + + return endpoints; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index 1d0c4feb..9981235b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -5,8 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Extensions { public static class UltimateAuthApplicationBuilderExtensions { - public static IApplicationBuilder UseUltimateAuthServer( - this IApplicationBuilder app) + public static IApplicationBuilder UseUltimateAuthServer(this IApplicationBuilder app) { app.UseMiddleware(); app.UseMiddleware(); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index c34701db..f5f65b6f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -1,8 +1,14 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +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.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Issuers; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; @@ -12,52 +18,62 @@ 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 UAuthServerServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthServer( - this IServiceCollection services) + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services) { - services.AddUltimateAuth(); // Core + services.AddUltimateAuth(); return services.AddUltimateAuthServerInternal(); } - public static IServiceCollection AddUltimateAuthServer( - this IServiceCollection services, - IConfiguration configuration) + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) { - services.AddUltimateAuth(configuration); // Core - services.Configure( - configuration.GetSection("UltimateAuth:Server")); - - services.Configure( - configuration.GetSection("UltimateAuth:SessionResolution")); + services.AddUltimateAuth(configuration); + services.Configure(configuration.GetSection("UltimateAuth:Server")); + services.Configure(configuration.GetSection("UltimateAuth:SessionResolution")); return services.AddUltimateAuthServerInternal(); } - public static IServiceCollection AddUltimateAuthServer( - this IServiceCollection services, - Action configure) + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action configure) { - services.AddUltimateAuth(); // Core + services.AddUltimateAuth(); services.Configure(configure); return services.AddUltimateAuthServerInternal(); } - private static IServiceCollection AddUltimateAuthServerInternal( - this IServiceCollection services) + private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) { + services.AddOptions() + .PostConfigure(o => + { + ConfigureDefaults.ApplyClientProfileDefaults(o); + ConfigureDefaults.ApplyModeDefaults(o); + }); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(sp => + { + var keyProvider = sp.GetRequiredService(); + var key = keyProvider.Resolve(null); + + return new HmacSha256TokenHasher( + ((SymmetricSecurityKey)key.Key).Key); + }); + + // ----------------------------- // OPTIONS VALIDATION // ----------------------------- - services.TryAddEnumerable( - ServiceDescriptor.Singleton< - IValidateOptions, - UAuthServerOptionsValidator>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, UAuthServerOptionsValidator>()); // ----------------------------- // TENANT RESOLUTION @@ -85,26 +101,87 @@ private static IServiceCollection AddUltimateAuthServerInternal( }; }); + // Inner resolvers + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Public resolver (tek!) + services.AddScoped(); + services.TryAddScoped(); - services.AddScoped(typeof(IUAuthFlowService), typeof(UAuthFlowService<>)); + services.AddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); services.AddScoped(typeof(IUAuthSessionService<>), typeof(UAuthSessionService<>)); services.AddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); services.AddScoped(typeof(IUAuthTokenService<>), typeof(UAuthTokenService<>)); + services.AddSingleton(); + + // TODO: Allow custom cookie manager via options + services.AddSingleton(); + //if (options.CustomCookieManagerType is not null) + //{ + // services.AddSingleton(typeof(IUAuthSessionCookieManager), options.CustomCookieManagerType); + //} + //else + //{ + // services.AddSingleton(); + //} + // ----------------------------- // SESSION / TOKEN ISSUERS // ----------------------------- - services.TryAddScoped( - typeof(UAuthSessionIssuer<>), - typeof(UAuthSessionIssuer<>)); + services.TryAddScoped(typeof(ISessionIssuer<>), typeof(UAuthSessionIssuer<>)); + services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + services.TryAddScoped(); + + services.TryAddScoped(typeof(IUserAuthenticator<>), typeof(DefaultUserAuthenticator<>)); + services.TryAddScoped(typeof(ISessionOrchestrator<>), typeof(UAuthSessionOrchestrator<>)); + services.TryAddScoped(); + services.TryAddScoped(typeof(ISessionQueryService<>), typeof(UAuthSessionQueryService<>)); + services.TryAddScoped(typeof(IRefreshTokenResolver<>), typeof(UAuthRefreshTokenResolver<>)); + services.TryAddScoped(); // ----------------------------- // ENDPOINTS // ----------------------------- services.TryAddSingleton(); + // Endpoint handlers + //services.TryAddScoped(typeof(ILoginEndpointHandler), typeof(DefaultLoginEndpointHandler<>)); + services.AddScoped>(); + services.AddScoped(); + //services.TryAddScoped(); + //services.TryAddScoped(); + //services.TryAddScoped(); + //services.TryAddScoped(); + //services.TryAddScoped(); + //services.TryAddScoped(); + + + return services; + } + + public static IServiceCollection AddUAuthServerInfrastructure(this IServiceCollection services) + { + // Flow orchestration + services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); + + // Issuers + services.TryAddScoped(typeof(ISessionIssuer<>), typeof(UAuthSessionIssuer<>)); + services.TryAddScoped(); + + // User service + services.TryAddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); + + // Endpoints + services.TryAddSingleton(); + + // Cookie management (default) + services.TryAddSingleton(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs index f2ffcece..c9fd6947 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class BearerSessionIdResolver : ISessionIdResolver + public sealed class BearerSessionIdResolver : IInnerSessionIdResolver { public AuthSessionId? Resolve(HttpContext context) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs index dd5c6f60..43ff3dd3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs @@ -1,27 +1,48 @@ 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 CompositeSessionIdResolver : ISessionIdResolver { - private readonly IReadOnlyList _resolvers; + private readonly IReadOnlyList _resolvers; - public CompositeSessionIdResolver(IEnumerable resolvers) + public CompositeSessionIdResolver(IEnumerable resolvers, IOptions options) { - _resolvers = resolvers.ToList(); + _resolvers = Order(resolvers, options.Value); } public AuthSessionId? Resolve(HttpContext context) { - foreach (var r in _resolvers) + foreach (var resolver in _resolvers) { - var id = r.Resolve(context); + var id = resolver.Resolve(context); if (id is not null) return id; } return null; } + + private static IReadOnlyList Order(IEnumerable resolvers, UAuthSessionResolutionOptions options) + { + var map = resolvers.ToDictionary( + r => r.GetType().Name.Replace("SessionIdResolver", ""), + r => r, + StringComparer.OrdinalIgnoreCase); + + var ordered = new List(); + + foreach (var key in options.Order) + { + if (map.TryGetValue(key, out var r)) + ordered.Add(r); + } + + return ordered; + } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs index c7d533e3..2e7f68b4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs @@ -1,26 +1,28 @@ 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 : ISessionIdResolver + public sealed class CookieSessionIdResolver : IInnerSessionIdResolver { - private readonly string _cookieName; + private readonly UAuthSessionResolutionOptions _options; - public CookieSessionIdResolver(string cookieName) + public CookieSessionIdResolver(IOptions options) { - _cookieName = cookieName; + _options = options.Value; } public AuthSessionId? Resolve(HttpContext context) { - if (!context.Request.Cookies.TryGetValue(_cookieName, out var raw)) + if (!context.Request.Cookies.TryGetValue(_options.CookieName, out var raw)) return null; - if (string.IsNullOrWhiteSpace(raw)) - return null; - - return new AuthSessionId(raw.Trim()); + return string.IsNullOrWhiteSpace(raw) + ? null + : new AuthSessionId(raw.Trim()); } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultDeviceResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultDeviceResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultJwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultJwtTokenGenerator.cs new file mode 100644 index 00000000..6d9f9006 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultJwtTokenGenerator.cs @@ -0,0 +1,71 @@ +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 DefaultJwtTokenGenerator : IJwtTokenGenerator + { + private readonly IJwtSigningKeyProvider _keyProvider; + private readonly JsonWebTokenHandler _handler = new(); + + public DefaultJwtTokenGenerator(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 + }; + + if (descriptor.TenantId is not null) + { + claims["tenant"] = descriptor.TenantId; + } + + 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/DefaultOpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultOpaqueTokenGenerator.cs new file mode 100644 index 00000000..8a31f216 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultOpaqueTokenGenerator.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class DefaultOpaqueTokenGenerator : IOpaqueTokenGenerator + { + public string Generate(int bytes) + => Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(bytes)); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs index fbfdcfde..0dfe9a47 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs @@ -8,7 +8,9 @@ public sealed class DefaultUserAuthenticator : IUserAuthenticator _userStore; private readonly IUAuthPasswordHasher _passwordHasher; - public DefaultUserAuthenticator(IUAuthUserStore userStore, IUAuthPasswordHasher passwordHasher) + public DefaultUserAuthenticator( + IUAuthUserStore userStore, + IUAuthPasswordHasher passwordHasher) { _userStore = userStore; _passwordHasher = passwordHasher; @@ -16,25 +18,30 @@ public DefaultUserAuthenticator(IUAuthUserStore userStore, IUAuthPasswo public async Task> AuthenticateAsync( string? tenantId, - string username, - string secret, - CancellationToken cancellationToken = default) + AuthenticationContext context, + CancellationToken ct = default) { - var user = await _userStore.FindByUsernameAsync( - tenantId, - username, - cancellationToken); + if (context is null) + throw new ArgumentNullException(nameof(context)); - if (user is null) + if (!string.Equals(context.CredentialType, "password", StringComparison.Ordinal)) return UserAuthenticationResult.Fail(); - if (!user.IsActive) + var user = await _userStore.FindByUsernameAsync( + tenantId, + context.Identifier, + ct); + + if (user is null || !user.IsActive) return UserAuthenticationResult.Fail(); - if (!_passwordHasher.Verify(secret, user.PasswordHash)) + if (!_passwordHasher.Verify(context.Secret, user.PasswordHash)) return UserAuthenticationResult.Fail(); - return UserAuthenticationResult.Success(user.Id, user.Claims, user.RequiresMfa); + return UserAuthenticationResult.Success( + user.Id, + user.Claims, + user.RequiresMfa); } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs new file mode 100644 index 00000000..d3eac437 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Server.Abstractions; +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) + { + // signing veya verify için tek key + return _key; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs index ca6521e6..71121d12 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs @@ -1,27 +1,26 @@ 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 : ISessionIdResolver + public sealed class HeaderSessionIdResolver : IInnerSessionIdResolver { - private readonly string _headerName; + private readonly UAuthSessionResolutionOptions _options; - public HeaderSessionIdResolver(string headerName) + public HeaderSessionIdResolver(IOptions options) { - _headerName = headerName; + _options = options.Value; } public AuthSessionId? Resolve(HttpContext context) { - if (!context.Request.Headers.TryGetValue(_headerName, out var values)) + if (!context.Request.Headers.TryGetValue(_options.HeaderName, out var values)) return null; var raw = values.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(raw)) - return null; - - return new AuthSessionId(raw.Trim()); + return string.IsNullOrWhiteSpace(raw) ? null : new AuthSessionId(raw); } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs new file mode 100644 index 00000000..ab493228 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs @@ -0,0 +1,37 @@ +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 plaintext, string hash) + { + var computed = Hash(plaintext); + + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(computed), + Convert.FromBase64String(hash)); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs new file mode 100644 index 00000000..0c75accc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface IInnerSessionIdResolver + { + AuthSessionId? Resolve(HttpContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs index 87de8da9..4df17305 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs @@ -2,6 +2,11 @@ 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/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs index d590374d..519f9eda 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs @@ -4,8 +4,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class UAuthSessionQueryService - : ISessionQueryService + public sealed class UAuthSessionQueryService : ISessionQueryService { private readonly ISessionStoreFactory _storeFactory; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs index 237a9b18..3ed77739 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs @@ -1,27 +1,29 @@ 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 : ISessionIdResolver + public sealed class QuerySessionIdResolver : IInnerSessionIdResolver { - private readonly string _parameterName; + private readonly UAuthSessionResolutionOptions _options; - public QuerySessionIdResolver(string parameterName) + public QuerySessionIdResolver(IOptions options) { - _parameterName = parameterName; + _options = options.Value; } public AuthSessionId? Resolve(HttpContext context) { - if (!context.Request.Query.TryGetValue(_parameterName, out var values)) + if (!context.Request.Query.TryGetValue(_options.QueryParameterName, out var values)) return null; var raw = values.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(raw)) - return null; - - return new AuthSessionId(raw.Trim()); + return string.IsNullOrWhiteSpace(raw) + ? null + : new AuthSessionId(raw.Trim()); } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs index 4aff464a..9f9419a0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs @@ -1,4 +1,4 @@ -using System.Security.Claims; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Infrastructure { @@ -6,7 +6,7 @@ public sealed record TokenIssuanceContext { public string UserId { get; init; } = default!; public string? TenantId { get; init; } - public IReadOnlyCollection Claims { get; init; } = Array.Empty(); + public IReadOnlyDictionary Claims { get; set; } = new Dictionary(); public string? SessionId { get; init; } public DateTimeOffset IssuedAt { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs deleted file mode 100644 index 9219aaa3..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs +++ /dev/null @@ -1,44 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class UAuthSessionIdResolver : ISessionIdResolver - { - private readonly ISessionIdResolver _inner; - - public UAuthSessionIdResolver(IOptions options) - { - var o = options.Value; - - var map = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Bearer"] = new BearerSessionIdResolver(), - ["Header"] = new HeaderSessionIdResolver(o.HeaderName), - ["Cookie"] = new CookieSessionIdResolver(o.CookieName), - ["Query"] = new QuerySessionIdResolver(o.QueryParameterName), - }; - - var list = new List(); - - foreach (var key in o.Order) - { - if (!map.TryGetValue(key, out var resolver)) - continue; - - if (key.Equals("Bearer", StringComparison.OrdinalIgnoreCase) && !o.EnableBearer) continue; - if (key.Equals("Header", StringComparison.OrdinalIgnoreCase) && !o.EnableHeader) continue; - if (key.Equals("Cookie", StringComparison.OrdinalIgnoreCase) && !o.EnableCookie) continue; - if (key.Equals("Query", StringComparison.OrdinalIgnoreCase) && !o.EnableQuery) continue; - - list.Add(resolver); - } - - _inner = new CompositeSessionIdResolver(list); - } - - public AuthSessionId? Resolve(Microsoft.AspNetCore.Http.HttpContext context) - => _inner.Resolve(context); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs index d9c85bb4..940bbccf 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class UAuthUserAccessor : IUserAccessor + public sealed class UAuthUserAccessor : IUserAccessor { private readonly ISessionStore _sessionStore; private readonly IUAuthUserStore _userStore; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs new file mode 100644 index 00000000..debf9658 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs @@ -0,0 +1,23 @@ +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/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs index b88bb9c1..6891d6d0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs @@ -3,26 +3,45 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Domain.Session; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; +using System.Net.Http; using System.Security; namespace CodeBeam.UltimateAuth.Server.Issuers { - public sealed class UAuthSessionIssuer : ISessionIssuer + public sealed class UAuthSessionIssuer : IHttpSessionIssuer { private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly ISessionStoreFactory _storeFactory; private readonly UAuthServerOptions _options; + private readonly IUAuthSessionCookieManager _cookieManager; - public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, ISessionStoreFactory storeFactory, IOptions options) + public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, ISessionStoreFactory storeFactory, IOptions options, IUAuthSessionCookieManager cookieManager) { _opaqueGenerator = opaqueGenerator; _storeFactory = storeFactory; _options = options.Value; + _cookieManager = cookieManager; } - public async Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default) + public Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) + { + return IssueLoginInternalAsync(httpContext: null, context, ct); + } + + public Task> IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) + { + if (httpContext is null) + throw new ArgumentNullException(nameof(httpContext)); + + return IssueLoginInternalAsync(httpContext, context, ct); + } + + private async Task> IssueLoginInternalAsync(HttpContext? httpContext, AuthenticatedSessionContext context, CancellationToken cancellationToken = default) { // Defensive guard — enforcement belongs to Authority if (_options.Mode == UAuthMode.PureJwt) @@ -98,10 +117,28 @@ await store.SetActiveSessionIdAsync( }; }); + //if (httpContext is not null) + //{ + // _cookieManager.Issue(httpContext, opaqueSessionId); + //} + return issued!; } - public async Task> RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) + public Task> RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) + { + return RotateInternalAsync(httpContext: null, context, ct); + } + + public Task> RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) + { + if (httpContext is null) + throw new ArgumentNullException(nameof(httpContext)); + + return RotateInternalAsync(httpContext, context, ct); + } + + private async Task> RotateInternalAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) { var now = context.Now; var store = _storeFactory.Create(context.TenantId); @@ -175,6 +212,11 @@ await store.RevokeSessionAsync( }; }); + if (httpContext is not null) + { + _cookieManager.Issue(httpContext, issued!.OpaqueSessionId); + } + return issued!; } diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs index e965827d..5aec4c0b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs @@ -20,13 +20,15 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; private readonly UAuthServerOptions _options; + private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IOptions options) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IOptions options, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; _options = options.Value; + _clock = clock; } public Task IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) @@ -85,32 +87,37 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, DateTimeOffset expires) { - var claims = new List + var claims = new Dictionary { - new Claim(ClaimTypes.NameIdentifier, context.UserId), - new Claim("tenant", context.TenantId) + ["sub"] = context.UserId, + ["tenant"] = context.TenantId }; - claims.AddRange(context.Claims); + foreach (var kv in context.Claims) + { + claims[kv.Key] = kv.Value; + } if (!string.IsNullOrWhiteSpace(context.SessionId)) { - claims.Add(new Claim("sid", context.SessionId)); + claims["sid"] = context.SessionId!; } if (_options.Tokens.AddJwtIdClaim) { - string jti = _opaqueGenerator.Generate(16); // shorter is fine - claims.Add(new Claim("jti", jti)); + claims["jti"] = _opaqueGenerator.Generate(16); } var descriptor = new UAuthJwtTokenDescriptor { - Subject = new ClaimsIdentity(claims), + Subject = context.UserId, Issuer = _options.Tokens.Issuer, Audience = _options.Tokens.Audience, - Expires = expires.UtcDateTime, - SigningKey = _options.Tokens.SigningKey + IssuedAt = _clock.UtcNow, + ExpiresAt = expires, + TenantId = context.TenantId, + Claims = claims, + KeyId = _options.Tokens.KeyId }; string jwt = _jwtGenerator.CreateToken(descriptor); @@ -123,5 +130,6 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, DateTimeOf SessionId = context.SessionId }; } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index 2f5380e1..bb4a5ffd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -2,28 +2,27 @@ 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; - private readonly ISessionIdResolver _sessionIdResolver; public const string SessionContextKey = "__UAuthSession"; - public SessionResolutionMiddleware( - RequestDelegate next, - ISessionIdResolver sessionIdResolver) + public SessionResolutionMiddleware(RequestDelegate next) { _next = next; - _sessionIdResolver = sessionIdResolver; } public async Task InvokeAsync(HttpContext context) { + var sessionIdResolver = context.RequestServices.GetRequiredService(); + var tenant = context.GetTenantContext(); - var sessionId = _sessionIdResolver.Resolve(context); + var sessionId = sessionIdResolver.Resolve(context); var sessionContext = sessionId is null ? SessionContext.Anonymous() diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index 6650b1f5..a9b82ffb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -2,45 +2,39 @@ 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; - private readonly ITenantResolver _resolver; - private readonly UAuthMultiTenantOptions _options; public const string TenantContextKey = "__UAuthTenant"; - public TenantMiddleware( - RequestDelegate next, - ITenantResolver resolver, - UAuthMultiTenantOptions options) + public TenantMiddleware(RequestDelegate next) { _next = next; - _resolver = resolver; - _options = options; } - public async Task InvokeAsync(HttpContext context) + public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOptions options) { + var opts = options.Value; + UAuthTenantContext tenantContext; - if (!_options.Enabled) + if (!opts.Enabled) { - // Single-tenant mode → tenant concept disabled tenantContext = UAuthTenantContext.NotResolved(); } else { - tenantContext = await _resolver.ResolveAsync(context); + tenantContext = await resolver.ResolveAsync(context); - if (_options.RequireTenant && !tenantContext.IsResolved) + if (opts.RequireTenant && !tenantContext.IsResolved) { context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync( - "Tenant is required but could not be resolved."); + await context.Response.WriteAsync("Tenant is required but could not be resolved."); return; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs index 079fbc81..77a41e16 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs @@ -1,26 +1,24 @@ 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; - private readonly IUserAccessor _userAccessor; public const string UserContextKey = "__UAuthUser"; - public UserMiddleware( - RequestDelegate next, - IUserAccessor userAccessor) + public UserMiddleware(RequestDelegate next) { _next = next; - _userAccessor = userAccessor; } public async Task InvokeAsync(HttpContext context) { - await _userAccessor.ResolveAsync(context); + var userAccessor = context.RequestServices.GetRequiredService(); + await userAccessor.ResolveAsync(context); await _next(context); } } diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs index 2988d915..ddbe0eb4 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.MultiTenancy { @@ -16,10 +17,10 @@ public sealed class UAuthTenantResolver : ITenantResolver public UAuthTenantResolver( ITenantIdResolver idResolver, - UAuthMultiTenantOptions options) + IOptions options) { _idResolver = idResolver; - _options = options; + _options = options.Value; } public async Task ResolveAsync(HttpContext context) diff --git a/src/CodeBeam.UltimateAuth.Server/Options/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/ConfigureDefaults.cs new file mode 100644 index 00000000..8c403f29 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/ConfigureDefaults.cs @@ -0,0 +1,122 @@ +using CodeBeam.UltimateAuth.Core; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + internal class ConfigureDefaults + { + internal static void ApplyClientProfileDefaults(UAuthServerOptions o) + { + if (o.ClientProfile == UAuthClientProfile.NotSpecified) + { + o.Mode ??= UAuthMode.Hybrid; + return; + } + + if (o.Mode is null) + { + o.Mode = o.ClientProfile switch + { + UAuthClientProfile.BlazorServer => UAuthMode.PureOpaque, + UAuthClientProfile.BlazorWasm => UAuthMode.SemiHybrid, + UAuthClientProfile.Maui => UAuthMode.SemiHybrid, + UAuthClientProfile.Mvc => UAuthMode.Hybrid, + UAuthClientProfile.Api => UAuthMode.PureJwt, + _ => throw new InvalidOperationException("Unsupported client profile. Please specify a client profile or make sure it's set NotSpecified") + }; + } + + if (o.HubDeploymentMode == default) + { + o.HubDeploymentMode = o.ClientProfile switch + { + UAuthClientProfile.BlazorWasm => UAuthHubDeploymentMode.Integrated, + UAuthClientProfile.Maui => UAuthHubDeploymentMode.Integrated, + _ => UAuthHubDeploymentMode.Embedded + }; + } + } + + internal static void ApplyModeDefaults(UAuthServerOptions o) + { + switch (o.Mode) + { + 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: {o.Mode}"); + } + } + + private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + + s.SlidingExpiration = true; + s.IdleTimeout ??= TimeSpan.FromHours(1); + s.MaxLifetime ??= TimeSpan.FromDays(7); + + t.IssueJwt = false; + t.IssueOpaque = false; + } + + private static void ApplyHybridDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + + s.SlidingExpiration = true; + + t.IssueJwt = true; + t.IssueOpaque = true; + t.AccessTokenLifetime = TimeSpan.FromMinutes(10); + t.RefreshTokenLifetime = TimeSpan.FromDays(7); + } + + private static void ApplySemiHybridDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + var p = o.Pkce; + + s.SlidingExpiration = false; + + t.IssueJwt = true; + t.IssueOpaque = true; + t.AccessTokenLifetime = TimeSpan.FromMinutes(10); + t.RefreshTokenLifetime = TimeSpan.FromDays(7); + t.AddJwtIdClaim = true; + } + + private static void ApplyPureJwtDefaults(UAuthServerOptions o) + { + var t = o.Tokens; + var p = o.Pkce; + + 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; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthClientProfile.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthClientProfile.cs new file mode 100644 index 00000000..3b59dce8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthClientProfile.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Server.Options +{ + public enum UAuthClientProfile + { + NotSpecified, + BlazorWasm, + BlazorServer, + Maui, + Mvc, + Api + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs new file mode 100644 index 00000000..e7728fae --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthCookieOptions +{ + public string Name { get; set; } = "uas"; + + /// + /// Controls whether the cookie is inaccessible to JavaScript. + /// Default: true (recommended). + /// + public bool HttpOnly { get; set; } = true; // TODO: Add UAUTH002 diagnostic if false? + + public CookieSecurePolicy SecurePolicy { get; set; } = CookieSecurePolicy.Always; + + internal SameSiteMode? SameSiteOverride { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyResolver.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyResolver.cs new file mode 100644 index 00000000..ce11dc99 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyResolver.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal static class UAuthCookiePolicyResolver +{ + public static SameSiteMode ResolveSameSite(UAuthServerOptions options) + { + if (options.Cookie.SameSiteOverride is not null) + return options.Cookie.SameSiteOverride.Value; + + return options.HubDeploymentMode switch + { + UAuthHubDeploymentMode.Embedded => SameSiteMode.Strict, + UAuthHubDeploymentMode.Integrated => SameSiteMode.Lax, + UAuthHubDeploymentMode.External => SameSiteMode.None, + _ => throw new InvalidOperationException() + }; + } +} 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/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index d094d536..8bbb703c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -13,11 +14,20 @@ namespace CodeBeam.UltimateAuth.Server.Options ///
public sealed class UAuthServerOptions { + public UAuthClientProfile ClientProfile { get; set; } + /// /// Defines how UltimateAuth executes authentication flows. /// Default is Hybrid. /// - public UAuthMode Mode { get; set; } = UAuthMode.Hybrid; + public UAuthMode? Mode { 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; // ------------------------------------------------------- // ROUTING @@ -59,6 +69,18 @@ public sealed class UAuthServerOptions ///
public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + /// + /// Allows advanced users to override cookie behavior. + /// Unsafe combinations will be rejected at startup. + /// + public UAuthCookieOptions Cookie { get; } = new(); + + internal Type? CustomCookieManagerType { get; private set; } + + public void ReplaceSessionCookieManager() where T : class, IUAuthSessionCookieManager + { + CustomCookieManagerType = typeof(T); + } // ------------------------------------------------------- // SERVER-ONLY BEHAVIOR diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs index 49b1d155..9bb49a5f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs @@ -16,10 +16,9 @@ public ValidateOptionsResult Validate( "RoutePrefix must be specified."); } - if (!options.RoutePrefix.StartsWith("/")) + if (options.RoutePrefix.Contains("//")) { - return ValidateOptionsResult.Fail( - "RoutePrefix must start with '/'."); + return ValidateOptionsResult.Fail("RoutePrefix cannot contain '//'."); } // ------------------------- diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index f2d4beb8..f70c648e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -2,16 +2,18 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Services { - internal sealed class UAuthFlowService : IUAuthFlowService + internal sealed class UAuthFlowService : IUAuthFlowService { private readonly IUAuthUserService _users; private readonly ISessionOrchestrator _orchestrator; private readonly ISessionQueryService _queries; private readonly ITokenIssuer _tokens; + private readonly ITokenStore _tokenStore; private readonly IRefreshTokenResolver _refreshTokens; public UAuthFlowService( @@ -19,12 +21,14 @@ public UAuthFlowService( ISessionOrchestrator orchestrator, ISessionQueryService queries, ITokenIssuer tokens, + ITokenStore tokenStore, IRefreshTokenResolver refreshTokens) { _users = users; _orchestrator = orchestrator; _queries = queries; _tokens = tokens; + _tokenStore = tokenStore; _refreshTokens = refreshTokens; } @@ -181,43 +185,63 @@ public Task ReauthenticateAsync(ReauthRequest request, Cancellatio public async Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default) { var now = DateTimeOffset.UtcNow; - var resolved = await _refreshTokens.ResolveAsync(request.TenantId, request.RefreshToken, now, ct); - if (resolved is null) - return SessionRefreshResult.Invalid(); + // Validate refresh token (STORE is authority) + var validation = await _tokenStore.ValidateRefreshTokenAsync( + request.TenantId, + request.RefreshToken, + now); - if (!resolved.IsValid) + if (!validation.IsValid) { - // TODO: Add reuse detection handling here - //if (resolved.IsReuseDetected) - //{ - // await _sessions.RevokeChainAsync( - // tenantId, - // resolved.Chain!.ChainId, - // now); - //} - - //return SessionRefreshResult.ReauthRequired(); + if (validation.IsReuseDetected && validation.SessionId is not null) + { + var chainId = await _queries.ResolveChainIdAsync( + request.TenantId, + validation.SessionId.Value, + ct); + + if (chainId is not null) + { + var authContext = AuthContext.System( + request.TenantId, + AuthOperation.Revoke, + now); + + await _orchestrator.ExecuteAsync( + authContext, + new RevokeChainCommand(chainId.Value), + ct); + } + } + + return SessionRefreshResult.ReauthRequired(); } - var session = resolved.Session; + var session = await _queries.GetSessionAsync(request.TenantId, validation.SessionId!.Value); + + if (session is null) + return SessionRefreshResult.ReauthRequired(); var rotationContext = new SessionRotationContext { TenantId = request.TenantId, - CurrentSessionId = session.SessionId, - UserId = session.UserId, + CurrentSessionId = validation.SessionId!.Value, + UserId = validation.UserId!, Now = now }; - var authContext = AuthContext.ForAuthenticatedUser(request.TenantId, AuthOperation.Refresh, now, DeviceContext.From(session.Device)); + var refreshAuthContext = AuthContext.ForAuthenticatedUser(request.TenantId, AuthOperation.Refresh, now, DeviceContext.From(session.Device)); - var issuedSession = await _orchestrator.ExecuteAsync(authContext, new RotateSessionCommand(rotationContext), ct); + var issuedSession = await _orchestrator.ExecuteAsync( + refreshAuthContext, + new RotateSessionCommand(rotationContext), + ct); var tokenContext = new TokenIssuanceContext { TenantId = request.TenantId, - UserId = session.UserId!.ToString()!, + UserId = validation.UserId!.ToString()!, SessionId = issuedSession.Session.SessionId }; diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs index 6d35fbf5..b3781d71 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs @@ -54,7 +54,7 @@ private TokenIssuanceContext ToIssuerContext(TokenIssueContext src) UserId = _userIdConverter.ToString(src.Session.UserId), TenantId = src.TenantId, SessionId = src.Session.SessionId, - Claims = src.Session.Claims.AsClaims() + Claims = src.Session.Claims.AsDictionary() }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs index 5a2fdba5..f76f804b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs @@ -1,89 +1,41 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Infrastructure; -namespace CodeBeam.UltimateAuth.Server.Users; - -internal sealed class UAuthUserService : IUAuthUserService +namespace CodeBeam.UltimateAuth.Server.Users { - private readonly IUAuthUserStore _userStore; - private readonly IUAuthPasswordHasher _passwordHasher; - private readonly IUserIdFactory _userIdFactory; - private readonly IUserAuthenticator _authenticator; - - public UAuthUserService( - IUAuthUserStore userStore, - IUAuthPasswordHasher passwordHasher, - IUserIdFactory userIdFactory, - IUserAuthenticator authenticator) - { - _userStore = userStore; - _passwordHasher = passwordHasher; - _userIdFactory = userIdFactory; - _authenticator = authenticator; - } - - public async Task RegisterAsync( - RegisterUserRequest request, - CancellationToken ct = default) + internal sealed class UAuthUserService : IUAuthUserService { - if (string.IsNullOrWhiteSpace(request.Identifier)) - throw new ArgumentException("Username is required."); - - if (string.IsNullOrWhiteSpace(request.Password)) - throw new ArgumentException("Password is required."); + private readonly IUserAuthenticator _authenticator; - if (await _userStore.ExistsByUsernameAsync(request.Identifier, ct)) - throw new InvalidOperationException("User already exists."); + public UAuthUserService(IUserAuthenticator authenticator) + { + _authenticator = authenticator; + } - var hash = _passwordHasher.Hash(request.Password); - - var userId = _userIdFactory.Create(); - - await _userStore.CreateAsync( - new UserRecord + public async Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken ct = default) + { + var context = new AuthenticationContext { - Id = userId, - Username = request.Identifier, - PasswordHash = hash, - CreatedAt = DateTimeOffset.UtcNow - }, - ct); - - return userId; - } - - public async Task ValidateCredentialsAsync( - ValidateCredentialsRequest request, - CancellationToken ct = default) - { - var user = await _userStore.FindByUsernameAsync(request.TenantId, request.Identifier, ct); - if (user is null) - return false; - - return _passwordHasher.Verify( - request.Password, - user.PasswordHash); - } - - public async Task DeleteAsync( - TUserId userId, - CancellationToken ct = default) - { - await _userStore.DeleteAsync(userId, ct); - } - - public async Task> AuthenticateAsync( - string? tenantId, - string identifier, - string secret, - CancellationToken cancellationToken = default) - { - return await _authenticator.AuthenticateAsync( - tenantId, - identifier, - secret, - cancellationToken); + Identifier = identifier, + Secret = secret, + CredentialType = "password" + }; + + return await _authenticator.AuthenticateAsync(tenantId, context, ct); + } + + // This method must not issue sessions or tokens + public async Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken ct = default) + { + var context = new AuthenticationContext + { + Identifier = request.Identifier, + Secret = request.Password, + CredentialType = "password" + }; + + var result = await _authenticator.AuthenticateAsync(request.TenantId,context, ct); + return result.Succeeded; + } } } - diff --git a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs index 44c57f10..b03c400a 100644 --- a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs +++ b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs @@ -1,10 +1,16 @@ -namespace CodeBeam.UltimateAuth.Server.Users +using CodeBeam.UltimateAuth.Server.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Users { /// /// Administrative user management operations. /// public interface IUAuthUserManagementService { + Task RegisterAsync(RegisterUserRequest request, CancellationToken cancellationToken = default); + + Task DeleteAsync(TUserId userId, CancellationToken cancellationToken = default); + Task> GetByIdAsync( TUserId userId, CancellationToken ct = default); @@ -24,5 +30,7 @@ Task ResetPasswordAsync( TUserId userId, ResetPasswordRequest request, CancellationToken ct = default); + + // TODO: Change password, Update user info, etc. } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs b/src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs index a5565b97..78cd9960 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs +++ b/src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Server.Users.Contracts { /// /// Request to register a new user with credentials. 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..63871f24 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj @@ -0,0 +1,14 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs new file mode 100644 index 00000000..a549909a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs @@ -0,0 +1,43 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory +{ + internal sealed class InMemoryCredentialUser : IUser + { + public UserId UserId { get; init; } + public string Username { get; init; } + + public string PasswordHash { get; private set; } = default!; + + public long SecurityVersion { get; private set; } + + public bool IsActive { get; init; } = true; + + IReadOnlyDictionary? IUser.Claims => null; + + public InMemoryCredentialUser( + UserId userId, + string username, + string passwordHash, + long securityVersion = 0, + bool isActive = true) + { + UserId = userId; + Username = username; + PasswordHash = passwordHash; + SecurityVersion = securityVersion; + IsActive = isActive; + } + + internal void SetPasswordHash(string passwordHash) + { + PasswordHash = passwordHash; + SecurityVersion++; + } + + internal void IncrementSecurityVersion() + { + SecurityVersion++; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs new file mode 100644 index 00000000..5fd5ff70 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory +{ + internal static class InMemoryCredentialSeeder + { + public static IReadOnlyCollection CreateDefaultUsers( + IUAuthPasswordHasher passwordHasher) + { + var adminUserId = UserId.New(); + + var passwordHash = passwordHasher.Hash("Password!"); + + var admin = new InMemoryCredentialUser( + userId: adminUserId, + username: "admin", + passwordHash: passwordHash, + securityVersion: 0, + isActive: true + ); + + return new[] { admin }; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs new file mode 100644 index 00000000..2836606d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory +{ + internal sealed class InMemoryUserStore : IUAuthUserStore + { + private readonly ConcurrentDictionary _usersByUsername; + private readonly ConcurrentDictionary _usersById; + + public InMemoryUserStore(IEnumerable seededUsers) + { + _usersByUsername = new ConcurrentDictionary( + StringComparer.OrdinalIgnoreCase); + + _usersById = new ConcurrentDictionary(); + + foreach (var user in seededUsers) + { + _usersByUsername[user.Username] = user; + _usersById[user.UserId] = user; + } + } + + public Task?> FindByIdAsync( + string? tenantId, + UserId userId, + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + + _usersById.TryGetValue(userId, out var user); + return Task.FromResult?>(user is { IsActive: true } ? user : null); + } + + public Task?> FindByUsernameAsync( + string? tenantId, + string username, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_usersByUsername.TryGetValue(username, out var user) || user.IsActive is false) + return Task.FromResult?>(null); + + // Core’daki UserRecord’u kullanıyorsun; InMemory tarafı buna map eder. + var record = new UserRecord + { + Id = user.UserId, + Username = user.Username, + PasswordHash = user.PasswordHash, + // ClaimsSnapshot varsa burada Empty bırakılabilir. + // Claims = ClaimsSnapshot.Empty, + RequiresMfa = false, + IsActive = user.IsActive, + CreatedAt = DateTimeOffset.UtcNow, + IsDeleted = false + }; + + return Task.FromResult?>(record); + } + + public Task?> FindByLoginAsync( + string? tenantId, + string login, + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + + _usersByUsername.TryGetValue(login, out var user); + return Task.FromResult?>(user is { IsActive: true } ? user : null); + } + + public Task GetPasswordHashAsync( + string? tenantId, + UserId userId, + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + + return Task.FromResult( + _usersById.TryGetValue(userId, out var user) + ? user.PasswordHash + : null); + } + + public Task SetPasswordHashAsync( + string? tenantId, + UserId userId, + string passwordHash, + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + + if (_usersById.TryGetValue(userId, out var user)) + { + user.SetPasswordHash(passwordHash); + } + + return Task.CompletedTask; + } + + public Task GetSecurityVersionAsync(string? tenantId, UserId userId, CancellationToken token = default) + { + return Task.FromResult( + _usersById.TryGetValue(userId, out var user) + ? user.SecurityVersion + : 0L); + } + + public Task IncrementSecurityVersionAsync(string? tenantId, UserId userId, CancellationToken token = default) + { + if (_usersById.TryGetValue(userId, out var user)) + { + user.IncrementSecurityVersion(); + } + + return Task.CompletedTask; + } + } +} 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..b313c260 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory +{ + public static class ServiceCollectionExtensions + { + /// + /// Registers the in-memory credential store with a default seeded user. + /// Intended for development, testing, and reference implementations. + /// + public static IServiceCollection AddInMemoryCredentials(this IServiceCollection services) + { + services.AddSingleton(sp => + { + var hasher = sp.GetService() + ?? throw new InvalidOperationException( + "IUAuthPasswordHasher is not registered. " + + "Call AddUltimateAuthArgon2() or register a custom hasher."); + + return InMemoryCredentialSeeder.CreateDefaultUsers(hasher); + }); + + services.AddSingleton>(sp => + { + var users = sp.GetRequiredService>(); + return new InMemoryUserStore(users); + }); + + return services; + } + } +} 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..fc8c324d --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs @@ -0,0 +1,13 @@ +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..a277e09c --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; +using System.Text; +using CodeBeam.UltimateAuth.Core.Abstractions; +using Konscious.Security.Cryptography; + +namespace CodeBeam.UltimateAuth.Security.Argon2 +{ + public sealed class Argon2PasswordHasher : IUAuthPasswordHasher + { + private readonly Argon2Options _options; + + public Argon2PasswordHasher(Argon2Options options) + { + _options = options; + } + + public string Hash(string password) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentException("Password cannot be null or empty.", nameof(password)); + + 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 password, string hash) + { + if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(hash)) + return false; + + var parts = hash.Split('.'); + if (parts.Length != 2) + return false; + + var salt = Convert.FromBase64String(parts[0]); + var expectedHash = Convert.FromBase64String(parts[1]); + + var argon2 = CreateArgon2(password, salt); + var actualHash = argon2.GetBytes(expectedHash.Length); + + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + } + + 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/CodeBeam.UltimateAuth.Security.Argon2.csproj b/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj new file mode 100644 index 00000000..b81bbdc9 --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + + + + + + + + + + + + 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..12593d48 --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +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) + { + var options = new Argon2Options(); + configure?.Invoke(options); + + services.AddSingleton(options); + 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..bf6c4919 --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Security.Argon2; +using CodeBeam.UltimateAuth.Server.Composition; +using Microsoft.Extensions.DependencyInjection; + +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/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..49bc1947 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj @@ -0,0 +1,14 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + + + + + + + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs new file mode 100644 index 00000000..3a4639db --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs @@ -0,0 +1,147 @@ +using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory; + +internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel +{ + private readonly SemaphoreSlim _tx = new(1, 1); + + private readonly ConcurrentDictionary> _sessions = new(); + private readonly ConcurrentDictionary> _chains = new(); + private readonly ConcurrentDictionary> _roots = new(); + private readonly ConcurrentDictionary _activeSessions = new(); + + public async Task ExecuteAsync(Func action) + { + await _tx.WaitAsync(); + try + { + await action(); + } + finally + { + _tx.Release(); + } + } + + public Task?> GetSessionAsync(string? _, AuthSessionId sessionId) + => Task.FromResult( + _sessions.TryGetValue(sessionId, out var s) ? s : null); + + public Task SaveSessionAsync(string? _, ISession session) + { + _sessions[session.SessionId] = session; + return Task.CompletedTask; + } + + public Task RevokeSessionAsync(string? _, AuthSessionId sessionId, DateTimeOffset at) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + _sessions[sessionId] = session.Revoke(at); + } + return Task.CompletedTask; + } + + public Task>> GetSessionsByChainAsync(string? _, ChainId chainId) + { + var result = _sessions.Values + .Where(s => s.ChainId == chainId) + .ToList(); + + return Task.FromResult>>(result); + } + + public Task?> GetChainAsync(string? _, ChainId chainId) + => Task.FromResult( + _chains.TryGetValue(chainId, out var c) ? c : null); + + public Task SaveChainAsync(string? _, ISessionChain chain) + { + _chains[chain.ChainId] = chain; + return Task.CompletedTask; + } + + public Task RevokeChainAsync(string? _, ChainId chainId, DateTimeOffset at) + { + if (_chains.TryGetValue(chainId, out var chain)) + { + _chains[chainId] = chain.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetActiveSessionIdAsync(string? _, ChainId chainId) + { + return Task.FromResult( + _activeSessions.TryGetValue(chainId, out var id) + ? id + : null + ); + } + + public Task SetActiveSessionIdAsync(string? _, ChainId chainId, AuthSessionId sessionId) + { + _activeSessions[chainId] = sessionId; + return Task.CompletedTask; + } + + public Task>> GetChainsByUserAsync(string? _, TUserId userId) + { + if (!_roots.TryGetValue(userId, out var root)) + return Task.FromResult>>(Array.Empty>()); + + return Task.FromResult>>(root.Chains.ToList()); + } + + public Task?> GetSessionRootAsync(string? _, TUserId userId) + => Task.FromResult(_roots.TryGetValue(userId, out var r) ? r : null); + + public Task SaveSessionRootAsync(string? _, ISessionRoot root) + { + _roots[root.UserId] = root; + return Task.CompletedTask; + } + + public Task RevokeSessionRootAsync(string? _, TUserId userId, DateTimeOffset at) + { + if (_roots.TryGetValue(userId, out var root)) + { + _roots[userId] = root.Revoke(at); + } + return Task.CompletedTask; + } + + public Task DeleteExpiredSessionsAsync(string? _, DateTimeOffset now) + { + foreach (var kvp in _sessions) + { + var session = kvp.Value; + + if (session.ExpiresAt <= now) + { + _sessions.TryGetValue(kvp.Key, out var existing); + + if (existing is not null) + { + _sessions.TryUpdate( + kvp.Key, + existing.Revoke(now), + existing); + } + } + } + + return Task.CompletedTask; + } + + public Task GetChainIdBySessionAsync(string? _, AuthSessionId sessionId) + { + if (_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(session.ChainId); + + return Task.FromResult(null); + } +} 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..08b5e523 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -0,0 +1,187 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Domain.Session; +using System.Security; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory; + +public sealed class InMemorySessionStore : ISessionStore +{ + private readonly ISessionStoreFactory _factory; + + public InMemorySessionStore(ISessionStoreFactory factory) + { + _factory = factory; + } + + private ISessionStoreKernel Kernel(string? tenantId) + => _factory.Create(tenantId); + + public Task?> GetSessionAsync( + string? tenantId, + AuthSessionId sessionId) + => Kernel(tenantId).GetSessionAsync(tenantId, sessionId); + + public async Task CreateSessionAsync( + IssuedSession issued, + SessionStoreContext ctx) + { + var k = Kernel(ctx.TenantId); + + await k.ExecuteAsync(async () => + { + var now = ctx.IssuedAt; + + // Root + var root = + await k.GetSessionRootAsync(ctx.TenantId, ctx.UserId) + ?? UAuthSessionRoot.Create( + ctx.TenantId, + ctx.UserId, + now); + + // Chain + ISessionChain chain; + + if (ctx.ChainId is not null) + { + chain = await k.GetChainAsync(ctx.TenantId, ctx.ChainId.Value) + ?? throw new InvalidOperationException("Chain not found."); + } + else + { + chain = UAuthSessionChain.Create( + ChainId.New(), + ctx.TenantId, + ctx.UserId, + root.SecurityVersion, + ClaimsSnapshot.Empty); + + root = root.AttachChain(chain, now); + } + + // Session + var session = UAuthSession.Create( + issued.Session.SessionId, + ctx.TenantId, + ctx.UserId, + chain.ChainId, + now, + issued.Session.ExpiresAt, + ctx.DeviceInfo, + issued.Session.Claims, + metadata: null); + + await k.SaveSessionRootAsync(ctx.TenantId, root); + await k.SaveChainAsync(ctx.TenantId, chain); + await k.SaveSessionAsync(ctx.TenantId, session); + await k.SetActiveSessionIdAsync( + ctx.TenantId, + chain.ChainId, + session.SessionId); + }); + } + + public async Task RotateSessionAsync( + AuthSessionId currentSessionId, + IssuedSession issued, + SessionStoreContext ctx) + { + var k = Kernel(ctx.TenantId); + + await k.ExecuteAsync(async () => + { + var now = ctx.IssuedAt; + + var old = await k.GetSessionAsync(ctx.TenantId, currentSessionId) + ?? throw new SecurityException("Session not found."); + + var chain = await k.GetChainAsync(ctx.TenantId, old.ChainId) + ?? throw new SecurityException("Chain not found."); + + var newSession = UAuthSession.Create( + issued.Session.SessionId, + ctx.TenantId, + ctx.UserId, + chain.ChainId, + now, + issued.Session.ExpiresAt, + ctx.DeviceInfo, + issued.Session.Claims, + metadata: null); + + await k.SaveSessionAsync(ctx.TenantId, newSession); + await k.SetActiveSessionIdAsync( + ctx.TenantId, + chain.ChainId, + newSession.SessionId); + + await k.RevokeSessionAsync( + ctx.TenantId, + currentSessionId, + now); + }); + } + + public Task RevokeSessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTimeOffset at) + => Kernel(tenantId).RevokeSessionAsync(tenantId, sessionId, at); + + public async Task RevokeAllSessionsAsync( + string? tenantId, + TUserId userId, + DateTimeOffset at) + { + var k = Kernel(tenantId); + + await k.ExecuteAsync(async () => + { + var root = await k.GetSessionRootAsync(tenantId, userId); + if (root is null) + return; + + foreach (var chain in root.Chains) + { + await k.RevokeChainAsync(tenantId, chain.ChainId, at); + + if (chain.ActiveSessionId is not null) + { + await k.RevokeSessionAsync( + tenantId, + chain.ActiveSessionId.Value, + at); + } + } + + await k.RevokeSessionRootAsync(tenantId, userId, at); + }); + } + + public async Task RevokeChainAsync( + string? tenantId, + ChainId chainId, + DateTimeOffset at) + { + var k = Kernel(tenantId); + + await k.ExecuteAsync(async () => + { + var chain = await k.GetChainAsync(tenantId, chainId); + if (chain is null) + return; + + await k.RevokeChainAsync(tenantId, chainId, at); + + if (chain.ActiveSessionId is not null) + { + await k.RevokeSessionAsync( + tenantId, + chain.ActiveSessionId.Value, + at); + } + }); + } +} 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..11285eda --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory +{ + public sealed class InMemorySessionStoreFactory : ISessionStoreFactory + { + private readonly ConcurrentDictionary _stores = new(); + + public ISessionStoreKernel Create(string? tenantId) + { + var key = tenantId ?? "__single__"; + + var store = _stores.GetOrAdd( + key, + _ => new InMemorySessionStoreKernel()); + + return (ISessionStoreKernel)store; + } + } +} 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..c9c0fe0a --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(typeof(ISessionStore<>), typeof(InMemorySessionStore<>)); + return services; + } + } +} 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..63871f24 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj @@ -0,0 +1,14 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + + + + + + + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs new file mode 100644 index 00000000..0e5f9b06 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs @@ -0,0 +1,122 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +internal sealed class InMemoryTokenStore : ITokenStore +{ + private readonly ITokenStoreFactory _factory; + private readonly ISessionStoreFactory _sessions; + private readonly ITokenHasher _hasher; + + public InMemoryTokenStore( + ITokenStoreFactory factory, + ISessionStoreFactory sessions, + ITokenHasher hasher) + { + _factory = factory; + _sessions = sessions; + _hasher = hasher; + } + + public async Task StoreRefreshTokenAsync( + string? tenantId, + TUserId userId, + AuthSessionId sessionId, + string refreshTokenHash, + DateTimeOffset expiresAt) + { + var kernel = _factory.Create(tenantId); + + var stored = new StoredRefreshToken + { + TokenHash = refreshTokenHash, + SessionId = sessionId, + ExpiresAt = expiresAt + }; + + await kernel.SaveRefreshTokenAsync(tenantId, stored); + } + + public async Task> ValidateRefreshTokenAsync(string? tenantId, string providedRefreshToken, DateTimeOffset now) + { + var kernel = _factory.Create(tenantId); + + var hash = _hasher.Hash(providedRefreshToken); + var stored = await kernel.GetRefreshTokenAsync(tenantId, hash); + + if (stored is null) + return RefreshTokenValidationResult.Invalid(); + + if (stored.IsRevoked) + return RefreshTokenValidationResult.ReuseDetected(); + + if (stored.ExpiresAt <= now) + { + await kernel.RevokeRefreshTokenAsync(tenantId, hash, now); + return RefreshTokenValidationResult.Invalid(); + } + + // one-time use + await kernel.RevokeRefreshTokenAsync(tenantId, hash, now); + + var sessionKernel = _sessions.Create(tenantId); + var session = await sessionKernel.GetSessionAsync(tenantId, stored.SessionId); + + if (session is null || session.IsRevoked || session.ExpiresAt <= now) + return RefreshTokenValidationResult.Invalid(); + + return RefreshTokenValidationResult.Valid( + session.UserId, + session.SessionId); + } + + public Task RevokeRefreshTokenAsync( + string? tenantId, + AuthSessionId sessionId, + DateTimeOffset at) + { + var kernel = _factory.Create(tenantId); + return kernel.RevokeAllRefreshTokensAsync(tenantId, null, at); + } + + public Task RevokeAllRefreshTokensAsync( + string? tenantId, + TUserId _, + DateTimeOffset at) + { + var kernel = _factory.Create(tenantId); + return kernel.RevokeAllRefreshTokensAsync(tenantId, null, at); + } + + // ------------------------------------------------------------ + // JTI + // ------------------------------------------------------------ + + public Task StoreTokenIdAsync( + string? tenantId, + string jti, + DateTimeOffset expiresAt) + { + var kernel = _factory.Create(tenantId); + return kernel.StoreTokenIdAsync(tenantId, jti, expiresAt); + } + + public Task IsTokenIdRevokedAsync( + string? tenantId, + string jti) + { + var kernel = _factory.Create(tenantId); + return kernel.IsTokenIdRevokedAsync(tenantId, jti); + } + + public Task RevokeTokenIdAsync( + string? tenantId, + string jti, + DateTimeOffset at) + { + var kernel = _factory.Create(tenantId); + return kernel.RevokeTokenIdAsync(tenantId, jti, at); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreFactory.cs new file mode 100644 index 00000000..909464d3 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreFactory.cs @@ -0,0 +1,18 @@ +using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +internal sealed class InMemoryTokenStoreFactory : ITokenStoreFactory +{ + private readonly ConcurrentDictionary _kernels = new(); + + public ITokenStoreKernel Create(string? tenantId) + { + var key = tenantId ?? "__single__"; + + return _kernels.GetOrAdd( + key, + _ => new InMemoryTokenStoreKernel()); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreKernel.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreKernel.cs new file mode 100644 index 00000000..3ce3dc63 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreKernel.cs @@ -0,0 +1,77 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +internal sealed class InMemoryTokenStoreKernel : ITokenStoreKernel +{ + private readonly ConcurrentDictionary _refreshTokens = new(); + private readonly ConcurrentDictionary _revokedJtis = new(); + + public Task SaveRefreshTokenAsync(string? _, StoredRefreshToken token) + { + _refreshTokens[token.TokenHash] = token; + return Task.CompletedTask; + } + + public Task GetRefreshTokenAsync(string? _, string tokenHash) + { + _refreshTokens.TryGetValue(tokenHash, out var token); + return Task.FromResult(token); + } + + public Task RevokeRefreshTokenAsync(string? _, string tokenHash, DateTimeOffset at) + { + if (_refreshTokens.TryGetValue(tokenHash, out var token)) + { + _refreshTokens[tokenHash] = token with { RevokedAt = at }; + } + + return Task.CompletedTask; + } + + public Task RevokeAllRefreshTokensAsync(string? _, string? __, DateTimeOffset at) + { + foreach (var kvp in _refreshTokens) + { + _refreshTokens[kvp.Key] = kvp.Value with { RevokedAt = at }; + } + + return Task.CompletedTask; + } + + public Task DeleteExpiredRefreshTokensAsync(string? _, DateTimeOffset now) + { + var dict = (IDictionary)_refreshTokens; + + foreach (var kvp in dict.ToList()) + { + if (kvp.Value.ExpiresAt <= now) + { + dict.Remove(kvp.Key); + } + } + + return Task.CompletedTask; + } + + // ------------------------------------------------------------ + // JWT ID (JTI) + // ------------------------------------------------------------ + + public Task StoreTokenIdAsync(string? _, string jti, DateTimeOffset expiresAt) + { + _revokedJtis[jti] = expiresAt; + return Task.CompletedTask; + } + + public Task IsTokenIdRevokedAsync(string? _, string jti) + => Task.FromResult(_revokedJtis.ContainsKey(jti)); + + public Task RevokeTokenIdAsync(string? _, string jti, DateTimeOffset at) + { + _revokedJtis[jti] = at; + return Task.CompletedTask; + } +} 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..6d8f88c4 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthInMemoryTokens(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(typeof(ITokenStore<>), typeof(InMemoryTokenStore<>)); + + return services; + } +} From 290ad958c79024f3902672f5a596341ab9db6928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:43:17 +0300 Subject: [PATCH 13/50] Create Main Flows (#7) * Create Main Flows * Complete Login Path with Minimum Required Features * Add Validation Flow * Logout Flow & Fix Cookie Options Conflict * Refresh Flow * Polish Flows --- ...Beam.UltimateAuth.Server.AspNetCore.csproj | 9 - UltimateAuth.slnx | 1 + .../Components/App.razor | 1 + .../Components/Layout/MainLayout.razor | 2 + .../Components/Pages/Home.razor | 50 +- .../Components/Pages/Home.razor.cs | 121 +- .../Components/_Imports.razor | 1 + .../UltimateAuth.BlazorServer/Program.cs | 40 +- .../UltimateAuth.BlazorServer.csproj | 20 +- .../App.razor | 12 + .../Layout/MainLayout.razor | 16 + .../Layout/MainLayout.razor.css | 77 + .../Layout/NavMenu.razor | 39 + .../Layout/NavMenu.razor.css | 83 + .../Pages/Counter.razor | 18 + .../Pages/Home.razor | 12 + .../Pages/Weather.razor | 57 + .../Program.cs | 16 + .../Properties/launchSettings.json | 25 + ...ateAuth.Sample.BlazorStandaloneWasm.csproj | 20 + .../_Imports.razor | 10 + .../wwwroot/css/app.css | 114 + .../wwwroot/favicon.png | Bin 0 -> 1148 bytes .../wwwroot/icon-192.png | Bin 0 -> 2626 bytes .../wwwroot/index.html | 32 + .../lib/bootstrap/dist/css/bootstrap-grid.css | 4085 ++++++ .../bootstrap/dist/css/bootstrap-grid.css.map | 1 + .../bootstrap/dist/css/bootstrap-grid.min.css | 6 + .../dist/css/bootstrap-grid.min.css.map | 1 + .../bootstrap/dist/css/bootstrap-grid.rtl.css | 4084 ++++++ .../dist/css/bootstrap-grid.rtl.css.map | 1 + .../dist/css/bootstrap-grid.rtl.min.css | 6 + .../dist/css/bootstrap-grid.rtl.min.css.map | 1 + .../bootstrap/dist/css/bootstrap-reboot.css | 597 + .../dist/css/bootstrap-reboot.css.map | 1 + .../dist/css/bootstrap-reboot.min.css | 6 + .../dist/css/bootstrap-reboot.min.css.map | 1 + .../dist/css/bootstrap-reboot.rtl.css | 594 + .../dist/css/bootstrap-reboot.rtl.css.map | 1 + .../dist/css/bootstrap-reboot.rtl.min.css | 6 + .../dist/css/bootstrap-reboot.rtl.min.css.map | 1 + .../dist/css/bootstrap-utilities.css | 5402 +++++++ .../dist/css/bootstrap-utilities.css.map | 1 + .../dist/css/bootstrap-utilities.min.css | 6 + .../dist/css/bootstrap-utilities.min.css.map | 1 + .../dist/css/bootstrap-utilities.rtl.css | 5393 +++++++ .../dist/css/bootstrap-utilities.rtl.css.map | 1 + .../dist/css/bootstrap-utilities.rtl.min.css | 6 + .../css/bootstrap-utilities.rtl.min.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.css | 12057 ++++++++++++++++ .../lib/bootstrap/dist/css/bootstrap.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.min.css | 6 + .../bootstrap/dist/css/bootstrap.min.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.rtl.css | 12030 +++++++++++++++ .../bootstrap/dist/css/bootstrap.rtl.css.map | 1 + .../bootstrap/dist/css/bootstrap.rtl.min.css | 6 + .../dist/css/bootstrap.rtl.min.css.map | 1 + .../lib/bootstrap/dist/js/bootstrap.bundle.js | 6314 ++++++++ .../bootstrap/dist/js/bootstrap.bundle.js.map | 1 + .../bootstrap/dist/js/bootstrap.bundle.min.js | 7 + .../dist/js/bootstrap.bundle.min.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.esm.js | 4447 ++++++ .../bootstrap/dist/js/bootstrap.esm.js.map | 1 + .../bootstrap/dist/js/bootstrap.esm.min.js | 7 + .../dist/js/bootstrap.esm.min.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.js | 4494 ++++++ .../lib/bootstrap/dist/js/bootstrap.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.min.js | 7 + .../bootstrap/dist/js/bootstrap.min.js.map | 1 + .../wwwroot/sample-data/weather.json | 27 + .../Abstractions/IBrowserPostClient.cs | 24 + .../Abstractions/ISessionCoordinator.cs | 16 + .../CodeBeam.UltimateAuth.Client.csproj | 21 +- .../Components/UALoginForm.razor | 19 + .../Components/UALoginForm.razor.cs | 35 + .../Components/UAuthClientProvider.razor | 9 + .../Components/UAuthClientProvider.razor.cs | 22 + .../Contracts/AuthValidationResult.cs | 11 + .../Contracts/BrowserPostResult.cs | 9 + .../Contracts/RefreshResult.cs | 11 + .../Extensions/LoginRequestFormExtensions.cs | 15 + ...teAuthClientServiceCollectionExtensions.cs | 106 + .../BlazorServerSessionCoordinator.cs | 77 + .../Infrastructure/BrowserPostClient.cs | 28 + .../Infrastructure/NoOpSessionCoordinator.cs | 16 + .../Infrastructure/RefreshOutcomeParser.cs | 21 + .../Internal/.gitkeep | 1 - .../Models/.gitkeep | 1 - .../Options/UAuthClientOptions.cs | 36 + .../Options/UAuthClientProfileDetector.cs | 25 + .../ProductInfo/UAuthClientProductInfo.cs | 10 + .../Services/.gitkeep | 1 - .../Services/IUAuthClient.cs | 16 + .../Services/UAuthClient.cs | 54 + .../_Imports.razor | 1 + .../wwwroot/uauth.js | 41 + .../Stores/ISessionActivityWriter.cs | 9 + .../CodeBeam.UltimateAuth.Core.csproj | 3 + .../Authority/DeviceMismatchBehavior.cs | 10 + .../Contracts/Login/LoginResult.cs | 1 + .../Contracts/Session/SessionContext.cs | 4 +- .../Contracts/Session/SessionRefreshResult.cs | 28 +- .../Session/SessionValidationResult.cs | 14 +- .../Contracts/Token/PrimaryToken.cs | 23 + .../Contracts/Token/PrimaryTokenKind.cs | 8 + .../Domain/Principals/AuthFailureReason.cs | 14 + .../Principals/PrimaryCredentialKind.cs | 8 + .../Domain/{ => Principals}/UAuthClaim.cs | 0 .../Domain/Session/AuthSessionId.cs | 17 +- .../Domain/Session/ISession.cs | 1 - .../Domain/Session/RefreshOutcome.cs | 10 + .../Domain/Session/UAuthSession.cs | 13 +- .../Domain/Token/SessionRefreshStatus.cs | 4 +- .../Domain/Token/TokenResponseMode.cs | 10 + ...UltimateAuthServiceCollectionExtensions.cs | 6 +- .../Options/IClientProfileDetector.cs | 9 + .../Options/IServerProfileDetector.cs | 9 + .../Options/UAuthClientProfile.cs | 2 +- .../Options/UAuthOptions.cs | 5 +- .../Options/UAuthSessionOptions.cs | 14 +- .../Options/UAuthTokenOptions.cs | 2 + .../Options/UAuthTokenOptionsValidator.cs | 6 + .../Runtime/IUAuthProductInfoProvider.cs | 9 + .../Runtime/UAuthProductInfo.cs | 17 + .../Runtime/UAuthProductInfoProvider.cs | 28 + .../Abstractions/ICredentialResolver.cs | 13 + .../Abstractions/ICredentialResponseWriter.cs | 10 + .../IPrimaryCredentialResolver.cs | 10 + .../Abstractions/ResolvedCredential.cs | 18 + .../CodeBeam.UltimateAuth.Server.csproj | 2 + .../Contracts/HeaderTokenFormat.cs | 8 + .../Contracts/LoginResponse.cs | 12 - .../Contracts/SessionRefreshResult.cs | 20 + .../Contracts/ValidateResponse.cs | 9 + .../Cookies/IUAuthCookieManager.cs | 12 + .../Cookies/IUAuthSessionCookieManager.cs | 14 - .../Cookies/UAuthSessionCookieManager.cs | 58 +- .../Abstractions/IRefreshEndpointHandler.cs | 9 + .../ISessionRefreshEndpointHandler.cs | 9 - .../Abstractions/IValidateEndpointHandler.cs | 9 + .../Endpoints/DefaultLoginEndpointHandler.cs | 65 +- .../Endpoints/DefaultLogoutEndpointHandler.cs | 28 +- .../DefaultRefreshEndpointHandler.cs | 95 + .../DefaultValidateEndpointHandler.cs | 97 + .../Endpoints/LogoutEndpointHandlerBridge.cs | 18 + .../Endpoints/RefreshEndpointHandlerBridge.cs | 19 + .../Endpoints/UAuthEndpointRegistrar.cs | 7 +- .../ValidateEndpointHandlerBridge.cs | 18 + .../Extensions/DeviceExtensions.cs | 18 + .../HttpContextSessionExtensions.cs | 4 +- .../UAuthServerServiceCollectionExtensions.cs | 44 +- .../CompositeSessionIdResolver.cs | 16 +- .../Infrastructure/CookieSessionIdResolver.cs | 6 +- .../DefaultCredentialResolver.cs | 77 + .../DefaultCredentialResponseWriter.cs | 57 + .../DefaultPrimaryCredentialResolver.cs | 39 + .../Infrastructure/DeviceInfoFactory.cs | 24 + .../Infrastructure/HeaderSessionIdResolver.cs | 6 +- .../Orchestrator/UAuthSessionQueryService.cs | 22 +- .../Infrastructure/QuerySessionIdResolver.cs | 6 +- .../Refresh/DefaultRefreshResponseWriter.cs | 32 + .../Refresh/DefaultSessionRefreshService.cs | 56 + .../Refresh/IRefreshResponseWriter.cs | 10 + .../Infrastructure/Refresh/IRefreshService.cs | 10 + .../Refresh/ISessionRefreshService.cs | 13 + .../Infrastructure/Refresh/RefreshDecision.cs | 33 + .../Refresh/RefreshDecisionResolver.cs | 25 + .../Refresh/RefreshEvaluationResult.cs | 6 + .../Session/DefaultSessionContextAccessor.cs | 30 + .../Session/ISessionContextAccessor.cs | 12 + .../Session/SessionContextItemKeys.cs | 7 + .../Issuers/UAuthSessionIssuer.cs | 14 +- .../SessionResolutionMiddleware.cs | 4 +- .../Options/AuthResponseOptions.cs | 14 + .../Options/ConfigureDefaults.cs | 122 - .../Options/CredentialResponseOptions.cs | 20 + .../Options/Defaults/ConfigureDefaults.cs | 223 + .../Options/LoginRedirectOptions.cs | 18 + .../Options/LogoutRedirectOptions.cs | 21 + .../Options/PrimaryCredentialPolicy.cs | 17 + .../Options/UAuthCookieOptions.cs | 18 + .../Options/UAuthDiagnosticsOptions.cs | 11 + .../Options/UAuthServerOptions.cs | 16 +- .../Options/UAuthServerProfileDetector.cs | 28 + .../Options/UAuthSessionResolutionOptions.cs | 3 +- .../ProductInfo/UAuthServerProductInfo.cs | 19 + .../Services/UAuthFlowService.cs | 4 +- .../CodeBeam.UltimateAuth.Server.Users.csproj | 1 + ...m.UltimateAuth.Credentials.InMemory.csproj | 1 + ...deBeam.UltimateAuth.Security.Argon2.csproj | 1 + ...Beam.UltimateAuth.Sessions.InMemory.csproj | 1 + .../InMemorySessionActivityWriter.cs | 22 + .../InMemorySessionStore.cs | 1 - .../ServiceCollectionExtensions.cs | 1 + ...deBeam.UltimateAuth.Tokens.InMemory.csproj | 1 + 195 files changed, 62647 insertions(+), 348 deletions(-) delete mode 100644 CodeBeam.UltimateAuth.Server.AspNetCore/CodeBeam.UltimateAuth.Server.AspNetCore.csproj create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/App.razor create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/UltimateAuth.Sample.BlazorStandaloneWasm.csproj create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/icon-192.png create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.js create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map create mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json create mode 100644 src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/AuthValidationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Internal/.gitkeep delete mode 100644 src/CodeBeam.UltimateAuth.Client/Models/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/_Imports.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs rename src/CodeBeam.UltimateAuth.Core/Domain/{ => Principals}/UAuthClaim.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenResponseMode.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/IServerProfileDetector.cs rename src/{CodeBeam.UltimateAuth.Server => CodeBeam.UltimateAuth.Core}/Options/UAuthClientProfile.cs (75%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/HeaderTokenFormat.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/ValidateResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthSessionCookieManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionRefreshEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IValidateEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultPrimaryCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponseWriter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponseWriter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshEvaluationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/DefaultSessionContextAccessor.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Options/ConfigureDefaults.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs diff --git a/CodeBeam.UltimateAuth.Server.AspNetCore/CodeBeam.UltimateAuth.Server.AspNetCore.csproj b/CodeBeam.UltimateAuth.Server.AspNetCore/CodeBeam.UltimateAuth.Server.AspNetCore.csproj deleted file mode 100644 index 983bc8d0..00000000 --- a/CodeBeam.UltimateAuth.Server.AspNetCore/CodeBeam.UltimateAuth.Server.AspNetCore.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net8.0;net9.0;net10.0 - - - - - diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 3f9c79cd..3ba93311 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,6 +1,7 @@ + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor index 22f3633f..9f3c4bac 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor @@ -21,6 +21,7 @@ + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor index e13fedec..69ed8f6d 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor @@ -1,5 +1,7 @@ @inherits LayoutComponentBase + + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor index 0287b897..f4a57bd2 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor @@ -1,21 +1,47 @@ @page "/" -@inject IUAuthFlowService FlowService +@page "/login" +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Runtime +@using CodeBeam.UltimateAuth.Server.Abstractions +@using CodeBeam.UltimateAuth.Server.Cookies +@using CodeBeam.UltimateAuth.Server.Infrastructure +@inject IUAuthFlowService Flow @inject ISnackbar Snackbar -@inject IHttpClientFactory Http +@inject ISessionQueryService SessionQuery +@inject ICredentialResolver CredentialResolver +@inject IClock Clock +@inject IUAuthCookieManager CookieManager +@inject IHttpContextAccessor HttpContextAccessor +@inject IUAuthClient UAuthClient +@inject NavigationManager Nav +@inject IUAuthProductInfoProvider ProductInfo +
-
- Welcome to UltimateAuth! - - - + + + Welcome to UltimateAuth! + + + Login + + + + + Validate + Logout + Refresh + - - - + + Programmatic Login + - Login - + + @ProductInfo.Get().ProductName v @ProductInfo.Get().Version + Client Profile: @ProductInfo.Get().ClientProfile.ToString() +
diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs index 64730ee2..6d506925 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs @@ -1,6 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using MudBlazor; namespace UltimateAuth.BlazorServer.Components.Pages @@ -10,38 +10,109 @@ public partial class Home private string? _username; private string? _password; - private async Task LoginAsync() + private UALoginForm _form = null!; + + private async Task ProgrammaticLogin() + { + var request = new LoginRequest + { + Identifier = "Admin", + Secret = "Password!", + }; + await UAuthClient.LoginAsync(request); + } + + private async Task ValidateAsync() { + var httpContext = HttpContextAccessor.HttpContext; - try + if (httpContext is null) { - //var result = await FlowService.LoginAsync(new LoginRequest - //{ - // Identifier = _username!, - // Secret = _password! - //}); - var client = Http.CreateClient(); - var result = await client.PostAsJsonAsync( - "https://localhost:7213/auth/login", - new LoginRequest - { - Identifier = _username!, - Secret = _password! - }); + Snackbar.Add("HttpContext not available", Severity.Error); + return; + } + var credential = CredentialResolver.Resolve(httpContext); - if (!result.IsSuccessStatusCode) + if (credential is null) + { + Snackbar.Add("No credential found", Severity.Error); + return; + } + + if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) + { + Snackbar.Add("Invalid session id", Severity.Error); + return; + } + + var result = await SessionQuery.ValidateSessionAsync( + new SessionValidationContext { - Snackbar.Add("Login failed.", Severity.Info); - return; - } + TenantId = credential.TenantId, + SessionId = sessionId, + Device = credential.Device, + Now = Clock.UtcNow + }); - Snackbar.Add("Successfully logged in!", Severity.Success); + if (result.IsValid) + { + Snackbar.Add("Session is valid ✅", Severity.Success); } - catch (Exception ex) + else { - Snackbar.Add(ex.ToString(), Severity.Error); + Snackbar.Add( + $"Session invalid ❌ ({result.State})", + Severity.Error); } } + + private async Task LogoutAsync() + { + await UAuthClient.LogoutAsync(); + Snackbar.Add("Logged out", Severity.Success); + } + + private async Task RefreshAsync() + { + await UAuthClient.RefreshAsync(); + //Snackbar.Add("Logged out", Severity.Success); + } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("error", out var error)) + { + ShowLoginError(error.ToString()); + ClearQueryString(); + } + } + } + + private void ShowLoginError(string code) + { + var message = code switch + { + "invalid" => "Invalid username or password.", + "locked" => "Your account is locked.", + "mfa" => "Multi-factor authentication required.", + _ => "Login failed." + }; + + Snackbar.Add(message, Severity.Error); + } + + private void ClearQueryString() + { + var uri = new Uri(Nav.Uri); + var clean = uri.GetLeftPart(UriPartial.Path); + Nav.NavigateTo(clean, replace: true); + } + } } diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor index 2b16dea5..7e568a67 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor @@ -11,6 +11,7 @@ @using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Client @using MudBlazor @using MudExtensions diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs b/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs index 0c127de8..12fb91c5 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs +++ b/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs @@ -1,3 +1,6 @@ +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.InMemory; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Extensions; @@ -12,7 +15,11 @@ // Add services to the container. builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); + .AddInteractiveServerComponents() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }); builder.Services.AddMudServices(); builder.Services.AddMudExtensions(); @@ -22,25 +29,40 @@ builder.Services.AddHttpContextAccessor(); +builder.Services.AddUltimateAuth(); -builder.Services.AddUltimateAuthServer() +builder.Services.AddUltimateAuthServer(o => { + o.Diagnostics.EnableRefreshHeaders = true; +}) .AddInMemoryCredentials() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() .AddUltimateAuthArgon2(); -builder.Services.AddHttpClient("AuthApi", client => -{ - client.BaseAddress = new Uri("https://localhost:7213"); -}) -.ConfigurePrimaryHttpMessageHandler(() => +builder.Services.AddUltimateAuthClient(); + +builder.Services.AddScoped(sp => { - return new HttpClientHandler + var navigation = sp.GetRequiredService(); + + return new HttpClient { - UseCookies = true + BaseAddress = new Uri(navigation.BaseUri) }; }); +//builder.Services.AddHttpClient("AuthApi", client => +//{ +// client.BaseAddress = new Uri("https://localhost:7213"); +//}) +//.ConfigurePrimaryHttpMessageHandler(() => +//{ +// return new HttpClientHandler +// { +// UseCookies = true +// }; +//}); + var app = builder.Build(); diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj b/samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj index fbfb55f2..d4044a07 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj +++ b/samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj @@ -1,17 +1,19 @@  - - net8.0 - enable - enable - + + net8.0 + enable + enable + 0.0.1-preview + - - - - + + + + + diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/App.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/App.razor new file mode 100644 index 00000000..6fd3ed1b --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/App.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor new file mode 100644 index 00000000..76eb7252 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor @@ -0,0 +1,16 @@ +@inherits LayoutComponentBase +
+ + +
+ + +
+ @Body +
+
+
diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.css new file mode 100644 index 00000000..ecf25e5b --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.css @@ -0,0 +1,77 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor new file mode 100644 index 00000000..109081ef --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor @@ -0,0 +1,39 @@ + + + + +@code { + private bool collapseNavMenu = true; + + private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; + + private void ToggleNavMenu() + { + collapseNavMenu = !collapseNavMenu; + } +} diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor.css new file mode 100644 index 00000000..617b89cc --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor.css @@ -0,0 +1,83 @@ +.navbar-toggler { + background-color: rgba(255, 255, 255, 0.1); +} + +.top-row { + min-height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep a { + color: #d7d7d7; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep a:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .collapse { + /* Never collapse the sidebar for wide screens */ + display: block; + } + + .nav-scrollable { + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor new file mode 100644 index 00000000..ef23cb31 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor @@ -0,0 +1,18 @@ +@page "/counter" + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor new file mode 100644 index 00000000..075f3e4a --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -0,0 +1,12 @@ +@page "/" +@using CodeBeam.UltimateAuth.Core.Runtime +@inject IUAuthProductInfoProvider ProductInfo + +Home + +

Hello, world!

+ +Welcome to your new app. + +@ProductInfo.Get().ProductName v @ProductInfo.Get().Version +Client Profile: @ProductInfo.Get().ClientProfile.ToString() diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor new file mode 100644 index 00000000..f2defcf2 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor @@ -0,0 +1,57 @@ +@page "/weather" +@inject HttpClient Http + +Weather + +

Weather

+ +

This component demonstrates fetching data from the server.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + forecasts = await Http.GetFromJsonAsync("sample-data/weather.json"); + } + + public class WeatherForecast + { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public string? Summary { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs new file mode 100644 index 00000000..c1d8465b --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Core.Extensions; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using UltimateAuth.Sample.BlazorStandaloneWasm; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +builder.Services.AddUltimateAuth(); +builder.Services.AddUltimateAuthClient(); + +await builder.Build().RunAsync(); diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json new file mode 100644 index 00000000..ce3dc89b --- /dev/null +++ b/samples/blazor-standalone-wasm/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:5008", + "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:7203;http://localhost:5008", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/UltimateAuth.Sample.BlazorStandaloneWasm.csproj b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/UltimateAuth.Sample.BlazorStandaloneWasm.csproj new file mode 100644 index 00000000..98c8f2df --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/UltimateAuth.Sample.BlazorStandaloneWasm.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + 0.0.1-preview + + + + + + + + + + + + + diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor new file mode 100644 index 00000000..34c73543 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor @@ -0,0 +1,10 @@ +@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 Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using UltimateAuth.Sample.BlazorStandaloneWasm +@using UltimateAuth.Sample.BlazorStandaloneWasm.Layout diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css new file mode 100644 index 00000000..7b3eb5d9 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css @@ -0,0 +1,114 @@ +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; +} \ No newline at end of file diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 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 literal 0 HcmV?d00001 diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html new file mode 100644 index 00000000..a85e8b4d --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html @@ -0,0 +1,32 @@ + + + + + + + UltimateAuth.Sample.BlazorStandaloneWasm + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css new file mode 100644 index 00000000..3882a819 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map new file mode 100644 index 00000000..ce99ec19 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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":"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EE,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;EA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;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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nEV;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;IAO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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;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AAA;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/l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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;I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","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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css new file mode 100644 index 00000000..49b843b1 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map new file mode 100644 index 00000000..a0db8b57 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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":"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","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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css new file mode 100644 index 00000000..1a5d6563 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map new file mode 100644 index 00000000..8df43cfc --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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;EA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;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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nEV;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;IAO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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;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AAA;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/l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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;I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","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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css new file mode 100644 index 00000000..672cbc2e --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map new file mode 100644 index 00000000..1c926af5 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css new file mode 100644 index 00000000..63054109 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map new file mode 100644 index 00000000..5fe522b6 --- /dev/null +++ b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/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":"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","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-` + } + + +@code { + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs new file mode 100644 index 00000000..07bc2a06 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CodeBeam.UltimateAuth.Client +{ + public partial class UALoginForm + { + [Parameter] + public string? Identifier { get; set; } + + [Parameter] + public string? Secret { get; set; } + + [Parameter] + public string? Endpoint { get; set; } = "/auth/login"; + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public bool AllowEnterKeyToSubmit { get; set; } = true; + + private ElementReference _form; + + private string ResolvedEndpoint => string.IsNullOrWhiteSpace(Endpoint) ? "/auth/login" : Endpoint; + + public async Task SubmitAsync() + { + if (_form.Context is null) + throw new InvalidOperationException("Form is not yet rendered. Call SubmitAsync after OnAfterRender."); + + await JS.InvokeVoidAsync("uauth.submitForm", _form); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor new file mode 100644 index 00000000..e743792b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor @@ -0,0 +1,9 @@ +@namespace CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Abstractions +@inject ISessionCoordinator Coordinator + +@implements IAsyncDisposable + +@code { + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs new file mode 100644 index 00000000..b30f5224 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs @@ -0,0 +1,22 @@ +namespace CodeBeam.UltimateAuth.Client +{ + // TODO: Add CircuitHandler to manage start/stop of coordinator in server-side Blazor + public partial class UAuthClientProvider + { + private bool _started; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _started) + return; + + _started = true; + await Coordinator.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await Coordinator.StopAsync(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/AuthValidationResult.cs new file mode 100644 index 00000000..23c102c8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/AuthValidationResult.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Client.Contracts +{ + public sealed record AuthValidationResult + { + public bool IsValid { get; init; } + public string? State { get; init; } + + public int? RemainingAttempts { get; init; } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostResult.cs new file mode 100644 index 00000000..1b48e995 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostResult.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Client.Contracts +{ + public sealed record BrowserPostResult + { + public bool Ok { get; init; } + public int Status { get; init; } + public string? RefreshOutcome { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs new file mode 100644 index 00000000..d60efdbe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Contracts +{ + public sealed record RefreshResult + { + public bool Ok { get; init; } + public int Status { get; init; } + public RefreshOutcome Outcome { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs new file mode 100644 index 00000000..a6e3e639 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs @@ -0,0 +1,15 @@ +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/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs new file mode 100644 index 00000000..9e88d23d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs @@ -0,0 +1,106 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +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 UltimateAuthClientServiceCollectionExtensions + { + /// + /// Registers UltimateAuth client services using configuration binding + /// (e.g. appsettings.json). + /// + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, IConfiguration configurationSection) + { + services.Configure(configurationSection); + return services.AddUltimateAuthClientInternal(); + } + + /// + /// Registers UltimateAuth client services using programmatic configuration. + /// + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, Action configure) + { + services.Configure(configure); + return services.AddUltimateAuthClientInternal(); + } + + /// + /// Registers UltimateAuth client services with default (empty) configuration. + /// + /// Intended for advanced scenarios where configuration is fully controlled + /// by the hosting application or overridden later. + /// + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services) + { + services.Configure(_ => { }); + 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) + { + // Options validation can be added here later if needed + // services.AddSingleton, ...>(); + + services.AddSingleton(); + services.PostConfigure(o => + { + if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) + return; + + using var sp = services.BuildServiceProvider(); + var detector = sp.GetRequiredService(); + o.ClientProfile = detector.Detect(sp); + }); + + services.PostConfigure(o => + { + o.Refresh.Interval ??= TimeSpan.FromMinutes(5); + }); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(sp => + { + var core = sp + .GetRequiredService>() + .Value; + + return core.ClientProfile == UAuthClientProfile.BlazorServer + ? sp.GetRequiredService() + : sp.GetRequiredService(); + }); + + services.AddScoped(); + services.AddScoped(); + + return services; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs new file mode 100644 index 00000000..e23b9154 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs @@ -0,0 +1,77 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure +{ + internal sealed class BlazorServerSessionCoordinator : ISessionCoordinator + { + private readonly IUAuthClient _client; + private readonly NavigationManager _navigation; + private readonly UAuthClientOptions _options; + + private PeriodicTimer? _timer; + private CancellationTokenSource? _cts; + + public BlazorServerSessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options) + { + _client = client; + _navigation = navigation; + _options = options.Value; + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + if (_timer is not null) + return; + + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var interval = _options.Refresh.Interval + ?? TimeSpan.FromMinutes(5); + + _timer = new PeriodicTimer(interval); + + _ = RunAsync(_cts.Token); + } + + private async Task RunAsync(CancellationToken ct) + { + try + { + while (await _timer!.WaitForNextTickAsync(ct)) + { + var result = await _client.RefreshAsync(); + + if (result.Outcome == RefreshOutcome.ReauthRequired) + { + _navigation.NavigateTo( + _options.Endpoints.Login, + forceLoad: true); + + return; + } + } + } + catch (OperationCanceledException) + { + // expected + } + } + + public Task StopAsync() + { + _cts?.Cancel(); + _timer?.Dispose(); + _timer = null; + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs new file mode 100644 index 00000000..147ed605 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using Microsoft.JSInterop; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure +{ + internal sealed class BrowserPostClient : IBrowserPostClient + { + private readonly IJSRuntime _js; + + public BrowserPostClient(IJSRuntime js) + { + _js = js; + } + + public Task NavigatePostAsync(string endpoint, IDictionary? data = null) + { + return _js.InvokeVoidAsync("uauth.post", endpoint, data).AsTask(); + } + + public async Task BackgroundPostAsync(string endpoint) + { + var result = await _js.InvokeAsync("uauth.refresh", endpoint); + return result; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs new file mode 100644 index 00000000..6e37f1b5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure +{ + internal sealed class NoOpSessionCoordinator : ISessionCoordinator + { + public Task StartAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task StopAsync() + => Task.CompletedTask; + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs new file mode 100644 index 00000000..b5ccf9a1 --- /dev/null +++ b/src/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.None; + + return value switch + { + "no-op" => RefreshOutcome.NoOp, + "touched" => RefreshOutcome.Touched, + "reauth-required" => RefreshOutcome.ReauthRequired, + _ => RefreshOutcome.None + }; + } + } +} 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/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs new file mode 100644 index 00000000..f3216d16 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -0,0 +1,36 @@ +namespace CodeBeam.UltimateAuth.Client.Options +{ + public sealed class UAuthClientOptions + { + public AuthEndpointOptions Endpoints { get; set; } = new(); + public UAuthClientRefreshOptions Refresh { get; set; } = new(); + } + + public sealed class AuthEndpointOptions + { + public string Login { get; set; } = "/auth/login"; + public string Logout { get; set; } = "/auth/logout"; + public string Refresh { get; set; } = "/auth/refresh"; + public string Reauth { get; set; } = "/auth/reauth"; + } + + public sealed class UAuthClientRefreshOptions + { + /// + /// 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; } + + /// + /// Optional jitter to avoid synchronized refresh storms. + /// + public TimeSpan? Jitter { get; set; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs new file mode 100644 index 00000000..0d8ed150 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Client.Options +{ + internal sealed class UAuthClientProfileDetector : IClientProfileDetector + { + public UAuthClientProfile Detect(IServiceProvider sp) + { + 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; + + //if (sp.GetService() is not null) + // return UAuthClientProfile.BlazorServer; + + //if (sp.GetService() is not null) + // return UAuthClientProfile.Mvc; + + return UAuthClientProfile.NotSpecified; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs b/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs new file mode 100644 index 00000000..4ad5ad14 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Runtime; + +namespace CodeBeam.UltimateAuth.Client.Runtime +{ + public sealed class UAuthClientProductInfo + { + public string ProductName { get; init; } = "UltimateAuthClient"; + public UAuthProductInfo Core { get; init; } = default!; + } +} 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/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs new file mode 100644 index 00000000..fc9f1805 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client +{ + public interface IUAuthClient + { + Task LoginAsync(LoginRequest request); + Task LogoutAsync(); + Task RefreshAsync(); + Task ReauthAsync(); + + Task ValidateAsync(); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs new file mode 100644 index 00000000..adea326d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -0,0 +1,54 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client +{ + internal sealed class UAuthClient : IUAuthClient + { + private readonly IBrowserPostClient _post; + private readonly UAuthClientOptions _options; + + public UAuthClient( + IBrowserPostClient post, + IOptions options) + { + _post = post; + _options = options.Value; + } + + public async Task LoginAsync(LoginRequest request) + => await _post.NavigatePostAsync(_options.Endpoints.Login, request.ToDictionary()); + + public async Task LogoutAsync() + => await _post.NavigatePostAsync(_options.Endpoints.Logout); + + public async Task RefreshAsync() + { + var result = await _post.BackgroundPostAsync( + _options.Endpoints.Refresh); + + return new RefreshResult + { + Ok = result.Ok, + Status = result.Status, + Outcome = RefreshOutcomeParser.Parse(result.RefreshOutcome) + }; + } + + public Task ReauthAsync() + => _post.NavigatePostAsync(_options.Endpoints.Reauth); + + public Task ValidateAsync() + { + // Blazor Server: direct service + // WASM: HttpClient + throw new NotImplementedException(); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Client/_Imports.razor b/src/CodeBeam.UltimateAuth.Client/_Imports.razor new file mode 100644 index 00000000..4b46211d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/_Imports.razor @@ -0,0 +1 @@ +@using Microsoft.JSInterop diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js new file mode 100644 index 00000000..b8e9ae75 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js @@ -0,0 +1,41 @@ +window.uauth = { + submitForm: function (form) { + if (form) { + form.submit(); + } + } +}; + +window.uauth = { + post: function (action, data) { + const form = document.createElement("form"); + form.method = "POST"; + form.action = action; + + 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(); + }, + + refresh: async function (action) { + const response = await fetch(action, { + method: "POST", + credentials: "include" + }); + + return { + ok: response.ok, + status: response.status, + refresh: response.headers.get("X-UAuth-Refresh") + }; + } +}; diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs new file mode 100644 index 00000000..8a2c9106 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface ISessionActivityWriter + { + Task TouchAsync(string? tenantId, ISession session, CancellationToken ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj index c03f964f..5a05299e 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -4,7 +4,10 @@ net8.0;net9.0;net10.0 enable enable + 0.0.1-preview + 0.0.1-preview true + $(NoWarn);1591 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..a4cd82ad --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum DeviceMismatchBehavior + { + Reject, // 401 + Allow, // Accept session + AllowAndRebind // Accept and update device info + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs index cc0d44fe..f9be49fb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -10,6 +10,7 @@ public sealed record LoginResult public AccessToken? AccessToken { get; init; } public RefreshToken? RefreshToken { get; init; } public LoginContinuation? Continuation { get; init; } + public AuthFailureReason? FailureReason { get; init; } // Helpers public bool IsSuccess => Status == LoginStatus.Success; diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs index 03af37ba..93d5aba9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs @@ -23,9 +23,7 @@ private SessionContext(AuthSessionId? sessionId, string? tenantId) public static SessionContext Anonymous() => new(null, null); - public static SessionContext FromSessionId( - AuthSessionId sessionId, - string? tenantId) + public static SessionContext FromSessionId(AuthSessionId sessionId, string? tenantId) => new(sessionId, tenantId); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs index 69ad1be6..fc7c9648 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -6,31 +6,43 @@ public sealed record SessionRefreshResult { public SessionRefreshStatus Status { get; init; } - public AccessToken? AccessToken { get; init; } + public PrimaryToken? PrimaryToken { get; init; } public RefreshToken? RefreshToken { get; init; } + public bool DidTouch { get; init; } public bool IsSuccess => Status == SessionRefreshStatus.Success; private SessionRefreshResult() { } public static SessionRefreshResult Success( - AccessToken accessToken, - RefreshToken? refreshToken) + PrimaryToken primaryToken, + RefreshToken? refreshToken = null, + bool didTouch = false) => new() { Status = SessionRefreshStatus.Success, - AccessToken = accessToken, - RefreshToken = refreshToken + PrimaryToken = primaryToken, + RefreshToken = refreshToken, + DidTouch = didTouch }; public static SessionRefreshResult ReauthRequired() + => new() + { + Status = SessionRefreshStatus.ReauthRequired + }; + + public static SessionRefreshResult InvalidRequest() => new() { - Status = SessionRefreshStatus.ReauthRequired + Status = SessionRefreshStatus.InvalidRequest }; - // TODO: ? - public static SessionRefreshResult Invalid() => new(); + public static SessionRefreshResult Failed() + => new() + { + Status = SessionRefreshStatus.Failed + }; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs index 26e9020b..f862000f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -4,17 +4,20 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed class SessionValidationResult { + public string? TenantId { get; } public SessionState State { get; } public ISession? Session { get; } public ISessionChain? Chain { get; } public ISessionRoot? Root { get; } private SessionValidationResult( - SessionState state, - ISession? session, - ISessionChain? chain, - ISessionRoot? root) + string? tenantId, + SessionState state, + ISession? session, + ISessionChain? chain, + ISessionRoot? root) { + TenantId = tenantId; State = state; Session = session; Chain = chain; @@ -24,10 +27,12 @@ private SessionValidationResult( public bool IsValid => State == SessionState.Active; public static SessionValidationResult Active( + string? tenantId, ISession session, ISessionChain chain, ISessionRoot root) => new( + tenantId, SessionState.Active, session, chain, @@ -36,6 +41,7 @@ public static SessionValidationResult Active( public static SessionValidationResult Invalid( SessionState state) => new( + tenantId: null, state, session: null, chain: null, 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..59f5de09 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs @@ -0,0 +1,23 @@ +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.Value); + + 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..821c3d19 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum PrimaryTokenKind + { + Session = 1, + AccessToken = 2 + } +} 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..24f16326 --- /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, + Unknown + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs new file mode 100644 index 00000000..e791ae67 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public enum PrimaryCredentialKind + { + Stateful, + Stateless + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/UAuthClaim.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/UAuthClaim.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Domain/UAuthClaim.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Principals/UAuthClaim.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index 70b73721..121f389c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -6,10 +6,7 @@ ///
public readonly struct AuthSessionId : IEquatable { - /// - /// Initializes a new using the specified GUID value. - /// - /// The underlying GUID representing the session identifier. + // TODO: Change this private public AuthSessionId(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -18,6 +15,18 @@ public AuthSessionId(string value) Value = value; } + public static bool TryCreate(string raw, out AuthSessionId sessionId) + { + if (string.IsNullOrWhiteSpace(raw)) + { + sessionId = default; + return false; + } + + sessionId = new AuthSessionId(raw); + return true; + } + /// /// Gets the underlying GUID value of the session identifier. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs index 869a0d93..9de7e7e3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -76,7 +76,6 @@ public interface ISession /// The evaluated of this session. SessionState GetState(DateTimeOffset now); - bool ShouldUpdateLastSeen(DateTimeOffset now); ISession Touch(DateTimeOffset now); ISession Revoke(DateTimeOffset at); 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..31885b4c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public enum RefreshOutcome + { + None, + NoOp, + Touched, + ReauthRequired + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 975d4d3d..1de4b275 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Domain.Session +namespace CodeBeam.UltimateAuth.Core.Domain { public sealed class UAuthSession : ISession { @@ -96,19 +96,8 @@ public UAuthSession WithSecurityVersion(long version) ); } - public bool ShouldUpdateLastSeen(DateTimeOffset at) - { - if (LastSeenAt is null) - return true; - - return (at - LastSeenAt.Value) >= TimeSpan.FromMinutes(1); - } - public ISession Touch(DateTimeOffset at) { - if (!ShouldUpdateLastSeen(at)) - return this; - return new UAuthSession( SessionId, TenantId, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs index 176bab4d..64e132d5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs @@ -3,6 +3,8 @@ public enum SessionRefreshStatus { Success, - ReauthRequired + ReauthRequired, + InvalidRequest, + Failed = 3 } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenResponseMode.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenResponseMode.cs new file mode 100644 index 00000000..af35d5dd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenResponseMode.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public enum TokenResponseMode + { + None, + Cookie, + Header, + Body + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs index 315d384f..8d3cb463 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Options; 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 @@ -82,6 +84,8 @@ private static IServiceCollection AddUltimateAuthInternal(this IServiceCollectio // Server layer may override or extend these settings. services.AddSingleton(); + services.TryAddSingleton(); + return services; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs b/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs new file mode 100644 index 00000000..42226d11 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Core.Options +{ + public interface IClientProfileDetector + { + UAuthClientProfile Detect(IServiceProvider services); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/IServerProfileDetector.cs b/src/CodeBeam.UltimateAuth.Core/Options/IServerProfileDetector.cs new file mode 100644 index 00000000..33af8f2b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/IServerProfileDetector.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Core.Options +{ + public interface IServerProfileDetector + { + UAuthClientProfile Detect(IServiceProvider services); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthClientProfile.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs similarity index 75% rename from src/CodeBeam.UltimateAuth.Server/Options/UAuthClientProfile.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs index 3b59dce8..ad76bd10 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthClientProfile.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Core.Options { public enum UAuthClientProfile { diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs index 2915ce2e..8992fb19 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs @@ -47,12 +47,15 @@ public sealed class UAuthOptions /// Multi-tenancy configuration controlling how tenants are resolved, /// validated, and optionally enforced. ///
- public UAuthMultiTenantOptions MultiTenantOptions { get; set; } = new(); + public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); /// /// Provides converters used to normalize and serialize TUserId /// across the system (sessions, stores, tokens, logging). /// public IUserIdConverterResolver? UserIdConverters { get; set; } + + public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; + public bool AutoDetectClientProfile { get; set; } = true; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 60776dff..90816cc7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Options +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Options { /// /// Defines configuration settings that control the lifecycle, @@ -26,6 +28,7 @@ public sealed class UAuthSessionOptions /// /// 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; @@ -35,6 +38,12 @@ public sealed class UAuthSessionOptions /// 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. @@ -76,5 +85,8 @@ public sealed class UAuthSessionOptions /// When enabled, UA mismatches can invalidate a session. /// public bool EnableUserAgentBinding { get; set; } = false; + + public DeviceMismatchBehavior DeviceMismatchBehavior { get; set; } = DeviceMismatchBehavior.Reject; + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs index fe67c8d6..4525aa96 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -19,6 +19,8 @@ public sealed class UAuthTokenOptions ///
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. diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs index d5baf784..c9de6e06 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs @@ -35,6 +35,12 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) errors.Add("Token.OpaqueIdBytes must be at least 16 (128-bit entropy)."); } + if (options.IssueRefresh && options.RefreshTokenLifetime <= TimeSpan.Zero) + { + errors.Add("RefreshTokenLifetime must be set when IssueRefresh is enabled."); + } + + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors); diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs new file mode 100644 index 00000000..e7345c0b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Runtime; + +namespace CodeBeam.UltimateAuth.Core.Runtime +{ + public interface IUAuthProductInfoProvider + { + UAuthProductInfo Get(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs new file mode 100644 index 00000000..3b28ca6a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Options; + +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 UAuthClientProfile ClientProfile { get; init; } + public bool ClientProfileAutoDetected { 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..d6da1567 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs @@ -0,0 +1,28 @@ +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, + + ClientProfile = options.Value.ClientProfile, + ClientProfileAutoDetected = options.Value.AutoDetectClientProfile, + StartedAt = DateTimeOffset.UtcNow + }; + } + + public UAuthProductInfo Get() => _info; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResolver.cs new file mode 100644 index 00000000..7f440403 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResolver.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Abstractions +{ + /// + /// Gets the credential from the HTTP context. + /// IPrimaryCredentialResolver is used to determine which kind of credential to resolve. + /// + public interface ICredentialResolver + { + ResolvedCredential? Resolve(HttpContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs new file mode 100644 index 00000000..5926ded8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Abstractions +{ + public interface ICredentialResponseWriter + { + void Write(HttpContext context, string value, CredentialResponseOptions options); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs new file mode 100644 index 00000000..554e9263 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Abstractions +{ + public interface IPrimaryCredentialResolver + { + PrimaryCredentialKind Resolve(HttpContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs new file mode 100644 index 00000000..417e8055 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Abstractions +{ + public sealed record ResolvedCredential + { + public PrimaryCredentialKind Kind { get; init; } + + /// + /// Raw credential value (session id / jwt / opaque) + /// + public string Value { get; init; } = default!; + + public string? TenantId { get; init; } = default!; + + public DeviceInfo Device { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index 8846acee..442f314e 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -4,7 +4,9 @@ net8.0;net9.0;net10.0 enable enable + 0.0.1-preview true + $(NoWarn);1591 diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/HeaderTokenFormat.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/HeaderTokenFormat.cs new file mode 100644 index 00000000..5fa3c2f4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/HeaderTokenFormat.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Server.Contracts +{ + public enum HeaderTokenFormat + { + Bearer, + Raw + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs deleted file mode 100644 index 48656db7..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Contracts -{ - public sealed record LoginResponse - { - public string? SessionId { get; init; } - public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } - public object? Continuation { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs new file mode 100644 index 00000000..6a017479 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs @@ -0,0 +1,20 @@ +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/Contracts/ValidateResponse.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/ValidateResponse.cs new file mode 100644 index 00000000..adc95c75 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/ValidateResponse.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Server.Contracts +{ + public sealed record ValidateResponse + { + public bool Valid { get; init; } + + public string? State { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs new file mode 100644 index 00000000..3161cdb5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Cookies; + +public interface IUAuthCookieManager +{ + void Write(HttpContext context, string value, Action? configure = null); + + bool TryRead(HttpContext context, out string value); + + void Delete(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthSessionCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthSessionCookieManager.cs deleted file mode 100644 index 359b38d7..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthSessionCookieManager.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Cookies; - -/// -/// Responsible for issuing, reading and revoking -/// UltimateAuth session cookies. -/// -public interface IUAuthSessionCookieManager -{ - void Issue(HttpContext context, string sessionId); - bool TryRead(HttpContext context, out string sessionId); - void Revoke(HttpContext context); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs index f723de85..69063f92 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Server.Cookies; -internal sealed class UAuthSessionCookieManager : IUAuthSessionCookieManager +internal sealed class UAuthSessionCookieManager : IUAuthCookieManager { private readonly UAuthServerOptions _options; @@ -13,31 +13,42 @@ public UAuthSessionCookieManager(IOptions options) _options = options.Value; } - public void Issue(HttpContext context, string sessionId) + public void Write(HttpContext context, string value, Action? configure = null) { - var cookieOptions = BuildCookieOptions(context); - context.Response.Cookies.Append(_options.Cookie.Name, sessionId, cookieOptions); + var options = BuildCookieOptions(context); + configure?.Invoke(options); + + context.Response.Cookies.Append(_options.Cookie.Name, value, options); } - public bool TryRead(HttpContext context, out string sessionId) + public bool TryRead(HttpContext context, out string value) { - return context.Request.Cookies.TryGetValue(_options.Cookie.Name, out sessionId!); + return context.Request.Cookies.TryGetValue(_options.Cookie.Name, out value!); } - public void Revoke(HttpContext context) + public void Delete(HttpContext context) { - context.Response.Cookies.Delete(_options.Cookie.Name, BuildCookieOptions(context)); + context.Response.Cookies.Delete(_options.Cookie.Name); } private CookieOptions BuildCookieOptions(HttpContext context) { - return new CookieOptions + var cookie = _options.Cookie; + var options = new CookieOptions { - HttpOnly = _options.Cookie.HttpOnly, - Secure = _options.Cookie.SecurePolicy == CookieSecurePolicy.Always, + HttpOnly = cookie.HttpOnly, + Secure = cookie.SecurePolicy == CookieSecurePolicy.Always, SameSite = ResolveSameSite(), - Path = "/" + Path = cookie.Path ?? "/" }; + + var maxAge = ResolveCookieMaxAge(); + if (maxAge is not null) + { + options.MaxAge = maxAge; + } + + return options; } private SameSiteMode ResolveSameSite() @@ -54,4 +65,27 @@ private SameSiteMode ResolveSameSite() }; } + private TimeSpan? ResolveCookieMaxAge() + { + var cookie = _options.Cookie; + var session = _options.Session; + var tokens = _options.Tokens; + + if (cookie.MaxAge is not null) + return cookie.MaxAge; + + if (tokens.IssueRefresh) + { + return tokens.RefreshTokenLifetime; + } + + if (session.IdleTimeout is not null) + { + return session.IdleTimeout + cookie.IdleBuffer; + } + + return null; + } + + } 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..b79cd0d7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs @@ -0,0 +1,9 @@ +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/ISessionRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionRefreshEndpointHandler.cs deleted file mode 100644 index 15261fda..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionRefreshEndpointHandler.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public interface ISessionRefreshEndpointHandler - { - Task RefreshSessionAsync(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..94a395ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IValidateEndpointHandler.cs @@ -0,0 +1,9 @@ +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/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index 2b28c2b1..1c248f5d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -1,12 +1,15 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Contracts; using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler { @@ -14,20 +17,26 @@ public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler private readonly IDeviceResolver _deviceResolver; private readonly ITenantResolver _tenantResolver; private readonly IClock _clock; - private readonly IUAuthSessionCookieManager _cookieManager; + private readonly IUAuthCookieManager _cookieManager; + private readonly ICredentialResponseWriter _credentialResponseWriter; + private readonly UAuthServerOptions _options; public DefaultLoginEndpointHandler( IUAuthFlowService flow, IDeviceResolver deviceResolver, ITenantResolver tenantResolver, IClock clock, - IUAuthSessionCookieManager cookieManager) + IUAuthCookieManager cookieManager, + ICredentialResponseWriter credentialResponseWriter, + IOptions options) { _flow = flow; _deviceResolver = deviceResolver; _tenantResolver = tenantResolver; _clock = clock; _cookieManager = cookieManager; + _credentialResponseWriter = credentialResponseWriter; + _options = options.Value; } public async Task LoginAsync(HttpContext ctx) @@ -37,20 +46,18 @@ public async Task LoginAsync(HttpContext ctx) var form = await ctx.Request.ReadFormAsync(); - var request = new LoginRequest - { - Identifier = form["Identifier"], - Secret = form["Secret"] - }; + var identifier = form["Identifier"].ToString(); + var secret = form["Secret"].ToString(); - if (string.IsNullOrWhiteSpace(request.Identifier) || - string.IsNullOrWhiteSpace(request.Secret)) - return Results.Redirect("/login?error=invalid"); + if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret)) + return RedirectFailure(AuthFailureReason.InvalidCredentials); var tenantCtx = ctx.GetTenantContext(); - var flowRequest = request with + var flowRequest = new LoginRequest { + Identifier = identifier, + Secret = secret, TenantId = tenantCtx.TenantId, At = _clock.UtcNow, DeviceInfo = _deviceResolver.Resolve(ctx) @@ -59,11 +66,41 @@ public async Task LoginAsync(HttpContext ctx) var result = await _flow.LoginAsync(flowRequest, ctx.RequestAborted); if (!result.IsSuccess) - return Results.Redirect("/login?error=invalid"); + return RedirectFailure(result.FailureReason ?? AuthFailureReason.Unknown); + + if (result.SessionId is not null) + { + _credentialResponseWriter.Write(ctx, result.SessionId.Value, _options.AuthResponse.SessionIdDelivery); + } + else if (result.AccessToken is not null) + { + _credentialResponseWriter.Write(ctx, result.AccessToken.Token, _options.AuthResponse.AccessTokenDelivery); + } + + if (result.RefreshToken is not null) + { + _credentialResponseWriter.Write(ctx, result.RefreshToken.Token, _options.AuthResponse.RefreshTokenDelivery); + } + + if (_options.AuthResponse.Login.RedirectEnabled) + { + return Results.Redirect(_options.AuthResponse.Login.SuccessRedirect); + } + + // TODO: Add PKCE, return result with body + + return Results.Ok(); + } + + private IResult RedirectFailure(AuthFailureReason reason) + { + var redirect = _options.AuthResponse.Login; - _cookieManager.Issue(ctx, result.SessionId!.Value); + var code = (redirect.FailureCodes != null && redirect.FailureCodes.TryGetValue(reason, out var c)) + ? c + : "failed"; - return Results.Redirect("/"); + return Results.Redirect($"{redirect.FailureRedirect}?{redirect.FailureQueryKey}={code}"); } //public async Task LoginAsync(HttpContext ctx) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs index 1084726b..0743b6ba 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs @@ -3,7 +3,9 @@ using CodeBeam.UltimateAuth.Server.Contracts; using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Endpoints { @@ -11,13 +13,15 @@ public sealed class DefaultLogoutEndpointHandler : ILogoutEndpointHandl { private readonly IUAuthFlowService _flow; private readonly IClock _clock; - private readonly IUAuthSessionCookieManager _cookies; + private readonly IUAuthCookieManager _cookieManager; + private readonly UAuthServerOptions _options; - public DefaultLogoutEndpointHandler(IUAuthFlowService flow, IClock clock, IUAuthSessionCookieManager cookieManager) + public DefaultLogoutEndpointHandler(IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, IOptions options) { _flow = flow; _clock = clock; - _cookies = cookieManager; + _cookieManager = cookieManager; + _options = options.Value; } public async Task LogoutAsync(HttpContext ctx) @@ -36,7 +40,23 @@ public async Task LogoutAsync(HttpContext ctx) }; await _flow.LogoutAsync(request, ctx.RequestAborted); - _cookies.Revoke(ctx); + _cookieManager.Delete(ctx); + + var logout = _options.AuthResponse.Logout; + + if (logout.RedirectEnabled) + { + var returnUrl = logout.AllowReturnUrlOverride + ? ctx.Request.Query["returnUrl"].FirstOrDefault() + : null; + + var redirect = !string.IsNullOrWhiteSpace(returnUrl) + ? returnUrl + : logout.RedirectUrl; + + // TODO: relative / same-origin check + return Results.Redirect(redirect); + } return Results.Ok(new LogoutResponse { diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs new file mode 100644 index 00000000..1f2a8411 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs @@ -0,0 +1,95 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public sealed class DefaultRefreshEndpointHandler : IRefreshEndpointHandler where TUserId : notnull + { + private readonly UAuthServerOptions _options; + private readonly ISessionContextAccessor _sessionContextAccessor; + private readonly ISessionQueryService _sessionQueries; + private readonly ISessionRefreshService _sessionRefresh; + private readonly ICredentialResponseWriter _credentialResponseWriter; + private readonly IRefreshResponseWriter _refreshResponseWriter; + + public DefaultRefreshEndpointHandler( + IOptions options, + ISessionContextAccessor sessionContextAccessor, + ISessionQueryService sessionQueries, + ISessionRefreshService sessionRefresh, + ICredentialResponseWriter credentialResponseWriter, + IRefreshResponseWriter refreshResponseWriter) + { + _options = options.Value; + _sessionContextAccessor = sessionContextAccessor; + _sessionQueries = sessionQueries; + _sessionRefresh = sessionRefresh; + _credentialResponseWriter = credentialResponseWriter; + _refreshResponseWriter = refreshResponseWriter; + } + + public async Task RefreshAsync(HttpContext ctx) + { + var decision = RefreshDecisionResolver.Resolve(_options); + + if (decision != RefreshDecision.SessionOnly) + { + // Endpoint exists, but this mode does not support session refresh + return Results.StatusCode(StatusCodes.Status409Conflict); + } + + var sessionContext = _sessionContextAccessor.Current; + if (sessionContext?.SessionId is null) + return Results.Unauthorized(); + + var now = DateTimeOffset.UtcNow; + var validation = await _sessionQueries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = sessionContext.TenantId, + SessionId = (AuthSessionId)sessionContext.SessionId, + Now = now, + Device = DeviceInfoFactory.FromHttpContext(ctx) + }, + ctx.RequestAborted); + + + if (!validation.IsValid) + return Results.Unauthorized(); + + var refreshResult = await _sessionRefresh.RefreshAsync(validation, now, ctx.RequestAborted); + + RefreshOutcome outcome; + + if (!refreshResult.IsSuccess || refreshResult.PrimaryToken is null) + { + outcome = RefreshOutcome.ReauthRequired; + + if (_options.Diagnostics.EnableRefreshHeaders) + _refreshResponseWriter.Write(ctx, outcome); + + return Results.Unauthorized(); + } + + _credentialResponseWriter.Write(ctx, refreshResult.PrimaryToken.Value, + new CredentialResponseOptions + { + Mode = TokenResponseMode.Cookie + }); + + outcome = refreshResult.DidTouch + ? RefreshOutcome.Touched + : RefreshOutcome.NoOp; + + if (_options.Diagnostics.EnableRefreshHeaders) + _refreshResponseWriter.Write(ctx, outcome); + + return Results.NoContent(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs new file mode 100644 index 00000000..9f55a9c6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs @@ -0,0 +1,97 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + internal sealed class DefaultValidateEndpointHandler : IValidateEndpointHandler + { + private readonly ICredentialResolver _credentialResolver; + private readonly ISessionQueryService _sessionValidator; + private readonly IClock _clock; + + public DefaultValidateEndpointHandler( + ICredentialResolver credentialResolver, + ISessionQueryService sessionValidator, + IClock clock) + { + _credentialResolver = credentialResolver; + _sessionValidator = sessionValidator; + _clock = clock; + } + + public async Task ValidateAsync( + HttpContext context, + CancellationToken ct = default) + { + var credential = _credentialResolver.Resolve(context); + + if (credential is null) + { + return Results.Json( + new ValidateResponse + { + Valid = false, + State = "missing" + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + if (credential.Kind == PrimaryCredentialKind.Stateful) + { + if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) + { + return Results.Json( + new ValidateResponse + { + Valid = false, + State = "invalid" + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + var result = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = credential.TenantId, + SessionId = sessionId, + Now = _clock.UtcNow, + Device = credential.Device + }, + ct); + + if (!result.IsValid) + { + return Results.Json( + new ValidateResponse + { + Valid = false, + State = result.State + .ToString() + .ToLowerInvariant() + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + return Results.Ok(new ValidateResponse { Valid = true }); + } + + // Stateless (JWT / Opaque) – 0.0.1 no support yet + return Results.Json( + new ValidateResponse + { + Valid = false, + State = "unsupported" + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs new file mode 100644 index 00000000..54095f22 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + internal sealed class LogoutEndpointHandlerBridge : ILogoutEndpointHandler + { + private readonly DefaultLogoutEndpointHandler _inner; + + public LogoutEndpointHandlerBridge(DefaultLogoutEndpointHandler inner) + { + _inner = inner; + } + + public Task LogoutAsync(HttpContext ctx) + => _inner.LogoutAsync(ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs new file mode 100644 index 00000000..28a885a8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + internal sealed class RefreshEndpointHandlerBridge : IRefreshEndpointHandler + { + private readonly DefaultRefreshEndpointHandler _inner; + + public RefreshEndpointHandlerBridge( + DefaultRefreshEndpointHandler inner) + { + _inner = inner; + } + + public Task RefreshAsync(HttpContext ctx) + => _inner.RefreshAsync(ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 6e9c0cd0..87079895 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -51,11 +51,14 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) => await h.LoginAsync(ctx)); + group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) + => await h.ValidateAsync(ctx)); + group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) => await h.LogoutAsync(ctx)); - group.MapPost("/refresh-session", async ([FromServices] ISessionRefreshEndpointHandler h, HttpContext ctx) - => await h.RefreshSessionAsync(ctx)); + group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) + => await h.RefreshAsync(ctx)); group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) => await h.ReauthAsync(ctx)); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs new file mode 100644 index 00000000..a5df8035 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + internal sealed class ValidateEndpointHandlerBridge : IValidateEndpointHandler + { + private readonly DefaultValidateEndpointHandler _inner; + + public ValidateEndpointHandlerBridge(DefaultValidateEndpointHandler inner) + { + _inner = inner; + } + + public Task ValidateAsync(HttpContext context, CancellationToken ct = default) + => _inner.ValidateAsync(context, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs new file mode 100644 index 00000000..84f1a4f4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Extensions +{ + public static class DeviceExtensions + { + public static DeviceInfo GetDevice(this HttpContext context) + { + var resolver = context.RequestServices + .GetRequiredService(); + + return resolver.Resolve(context); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs index 4d3063fa..4208268f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs @@ -1,5 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Middlewares; +using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions @@ -8,7 +8,7 @@ public static class HttpContextSessionExtensions { public static SessionContext GetSessionContext(this HttpContext context) { - if (context.Items.TryGetValue(SessionResolutionMiddleware.SessionContextKey, out var value) + if (context.Items.TryGetValue(SessionContextItemKeys.SessionContext, out var value) && value is SessionContext session) { return session; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index f5f65b6f..60113f9f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Session; using CodeBeam.UltimateAuth.Server.Issuers; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; @@ -34,7 +35,6 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s { services.AddUltimateAuth(configuration); services.Configure(configuration.GetSection("UltimateAuth:Server")); - services.Configure(configuration.GetSection("UltimateAuth:SessionResolution")); return services.AddUltimateAuthServerInternal(); } @@ -49,13 +49,26 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) { + services.AddSingleton(); + services.PostConfigure(o => + { + if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) + return; + + using var sp = services.BuildServiceProvider(); + var detector = sp.GetRequiredService(); + o.ClientProfile = detector.Detect(sp); + }); + services.AddOptions() - .PostConfigure(o => + .PostConfigure>((server, core) => { - ConfigureDefaults.ApplyClientProfileDefaults(o); - ConfigureDefaults.ApplyModeDefaults(o); + ConfigureDefaults.ApplyClientProfileDefaults(server, core.Value); + ConfigureDefaults.ApplyModeDefaults(server); + ConfigureDefaults.ApplyAuthResponseDefaults(server, core.Value); }); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -120,7 +133,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddSingleton(); // TODO: Allow custom cookie manager via options - services.AddSingleton(); + services.AddSingleton(); //if (options.CustomCookieManagerType is not null) //{ // services.AddSingleton(typeof(IUAuthSessionCookieManager), options.CustomCookieManagerType); @@ -144,7 +157,13 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(typeof(ISessionQueryService<>), typeof(UAuthSessionQueryService<>)); services.TryAddScoped(typeof(IRefreshTokenResolver<>), typeof(UAuthRefreshTokenResolver<>)); + services.TryAddScoped(typeof(ISessionRefreshService<>), typeof(DefaultSessionRefreshService<>)); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); // ----------------------------- // ENDPOINTS @@ -153,9 +172,16 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol // Endpoint handlers //services.TryAddScoped(typeof(ILoginEndpointHandler), typeof(DefaultLoginEndpointHandler<>)); services.AddScoped>(); - services.AddScoped(); - //services.TryAddScoped(); - //services.TryAddScoped(); + services.TryAddScoped(); + + services.AddScoped>(); + services.TryAddScoped(); + + services.AddScoped>(); + services.TryAddScoped(); + + services.AddScoped>(); + services.TryAddScoped(); //services.TryAddScoped(); //services.TryAddScoped(); //services.TryAddScoped(); @@ -181,7 +207,7 @@ public static IServiceCollection AddUAuthServerInfrastructure(this IServiceColle services.TryAddSingleton(); // Cookie management (default) - services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs index 43ff3dd3..3a8aafa4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs @@ -9,7 +9,7 @@ public sealed class CompositeSessionIdResolver : ISessionIdResolver { private readonly IReadOnlyList _resolvers; - public CompositeSessionIdResolver(IEnumerable resolvers, IOptions options) + public CompositeSessionIdResolver(IEnumerable resolvers, IOptions options) { _resolvers = Order(resolvers, options.Value); } @@ -26,21 +26,29 @@ public CompositeSessionIdResolver(IEnumerable resolvers return null; } - private static IReadOnlyList Order(IEnumerable resolvers, UAuthSessionResolutionOptions options) + private static IReadOnlyList Order(IEnumerable resolvers, UAuthServerOptions options) { - var map = resolvers.ToDictionary( + var list = resolvers.ToList(); + + if (options.SessionResolution.Order is null || options.SessionResolution.Order.Count == 0) + return list; + + var map = list.ToDictionary( r => r.GetType().Name.Replace("SessionIdResolver", ""), r => r, StringComparer.OrdinalIgnoreCase); var ordered = new List(); - foreach (var key in options.Order) + foreach (var key in options.SessionResolution.Order) { if (map.TryGetValue(key, out var r)) ordered.Add(r); } + if (ordered.Count == 0) + return list; + return ordered; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs index 2e7f68b4..a0cd09f6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs @@ -7,16 +7,16 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class CookieSessionIdResolver : IInnerSessionIdResolver { - private readonly UAuthSessionResolutionOptions _options; + private readonly UAuthServerOptions _options; - public CookieSessionIdResolver(IOptions options) + public CookieSessionIdResolver(IOptions options) { _options = options.Value; } public AuthSessionId? Resolve(HttpContext context) { - if (!context.Request.Cookies.TryGetValue(_options.CookieName, out var raw)) + if (!context.Request.Cookies.TryGetValue(_options.Cookie.Name, out var raw)) return null; return string.IsNullOrWhiteSpace(raw) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResolver.cs new file mode 100644 index 00000000..7b397cbd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResolver.cs @@ -0,0 +1,77 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +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 DefaultCredentialResolver : ICredentialResolver + { + private readonly IPrimaryCredentialResolver _primaryResolver; + private readonly UAuthServerOptions _options; + + public DefaultCredentialResolver( + IPrimaryCredentialResolver primaryResolver, + IOptions options) + { + _primaryResolver = primaryResolver; + _options = options.Value; + } + + public ResolvedCredential? Resolve(HttpContext context) + { + var primary = _primaryResolver.Resolve(context); + + return primary switch + { + PrimaryCredentialKind.Stateful => ResolveSession(context), + PrimaryCredentialKind.Stateless => ResolveToken(context), + _ => null + }; + } + + private ResolvedCredential? ResolveSession(HttpContext context) + { + if (!context.Request.Cookies.TryGetValue( + _options.Cookie.Name, + out var sessionId)) + { + return null; + } + + return new ResolvedCredential + { + Kind = PrimaryCredentialKind.Stateful, + Value = sessionId, + TenantId = context.GetTenantContext().TenantId, + Device = context.GetDevice() + }; + } + + private ResolvedCredential? ResolveToken(HttpContext context) + { + if (!context.Request.Headers.TryGetValue("Authorization", out var header)) + return null; + + var value = header.ToString(); + + if (value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + value = value["Bearer ".Length..].Trim(); + } + + if (string.IsNullOrWhiteSpace(value)) + return null; + + return new ResolvedCredential + { + Kind = PrimaryCredentialKind.Stateless, + Value = value, + TenantId = context.GetTenantContext().TenantId, + Device = context.GetDevice() + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs new file mode 100644 index 00000000..92df1111 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs @@ -0,0 +1,57 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Cookies; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class DefaultCredentialResponseWriter : ICredentialResponseWriter + { + private readonly IUAuthCookieManager _cookieManager; + + public DefaultCredentialResponseWriter( + IUAuthCookieManager cookieManager) + { + _cookieManager = cookieManager; + } + + public void Write(HttpContext context, string value, CredentialResponseOptions options) + { + switch (options.Mode) + { + case TokenResponseMode.Cookie: + _cookieManager.Write(context, value); + break; + + case TokenResponseMode.Header: + WriteHeader(context, value, options); + break; + + case TokenResponseMode.Body: + // Intentionally NO-OP here. + // Body is composed by the endpoint response. + break; + + case TokenResponseMode.None: + default: + break; + } + } + + private static void WriteHeader( HttpContext context, string value, CredentialResponseOptions options) + { + var headerName = options.Name ?? "Authorization"; + + var formatted = options.HeaderFormat switch + { + HeaderTokenFormat.Bearer => $"Bearer {value}", + HeaderTokenFormat.Raw => value, + _ => value + }; + + context.Response.Headers[headerName] = formatted; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultPrimaryCredentialResolver.cs new file mode 100644 index 00000000..90a2b016 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultPrimaryCredentialResolver.cs @@ -0,0 +1,39 @@ +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 DefaultPrimaryCredentialResolver : IPrimaryCredentialResolver + { + private readonly UAuthServerOptions _options; + + public DefaultPrimaryCredentialResolver(IOptions options) + { + _options = options.Value; + } + + public PrimaryCredentialKind 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/DeviceInfoFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs new file mode 100644 index 00000000..39b906b7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public static class DeviceInfoFactory + { + public static DeviceInfo FromHttpContext(HttpContext context) + { + return new DeviceInfo + { + DeviceId = ResolveDeviceId(context), + Platform = context.Request.Headers.UserAgent.ToString(), + UserAgent = context.Request.Headers.UserAgent.ToString() + }; + } + + private static string ResolveDeviceId(HttpContext context) + { + // TODO: cookie / fingerprint / header in future + return context.Request.Headers.UserAgent.ToString(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs index 71121d12..8ee19833 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs @@ -7,16 +7,16 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class HeaderSessionIdResolver : IInnerSessionIdResolver { - private readonly UAuthSessionResolutionOptions _options; + private readonly UAuthServerOptions _options; - public HeaderSessionIdResolver(IOptions options) + public HeaderSessionIdResolver(IOptions options) { _options = options.Value; } public AuthSessionId? Resolve(HttpContext context) { - if (!context.Request.Headers.TryGetValue(_options.HeaderName, out var values)) + if (!context.Request.Headers.TryGetValue(_options.SessionResolution.HeaderName, out var values)) return null; var raw = values.FirstOrDefault(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs index 519f9eda..40aeadc5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs @@ -1,21 +1,23 @@ 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.Infrastructure { public sealed class UAuthSessionQueryService : ISessionQueryService { private readonly ISessionStoreFactory _storeFactory; + private readonly UAuthServerOptions _options; - public UAuthSessionQueryService(ISessionStoreFactory storeFactory) + public UAuthSessionQueryService(ISessionStoreFactory storeFactory, IOptions options) { _storeFactory = storeFactory; + _options = options.Value; } - public async Task> ValidateSessionAsync( - SessionValidationContext context, - CancellationToken ct = default) + public async Task> ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) { var kernel = _storeFactory.Create(context.TenantId); @@ -47,17 +49,11 @@ public async Task> ValidateSessionAsync( if (session.SecurityVersionAtCreation != root.SecurityVersion) return SessionValidationResult.Invalid(SessionState.SecurityMismatch); - if (!session.Device.Matches(context.Device)) + // TODO: Implement AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. + if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) return SessionValidationResult.Invalid(SessionState.DeviceMismatch); - if (session.ShouldUpdateLastSeen(context.Now)) - { - var updated = session.Touch(context.Now); - await kernel.SaveSessionAsync(context.TenantId, updated); - session = updated; - } - - return SessionValidationResult.Active(session, chain, root); + return SessionValidationResult.Active(context.TenantId, session, chain, root); } public Task?> GetSessionAsync( diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs index 3ed77739..cd615db8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs @@ -7,16 +7,16 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class QuerySessionIdResolver : IInnerSessionIdResolver { - private readonly UAuthSessionResolutionOptions _options; + private readonly UAuthServerOptions _options; - public QuerySessionIdResolver(IOptions options) + public QuerySessionIdResolver(IOptions options) { _options = options.Value; } public AuthSessionId? Resolve(HttpContext context) { - if (!context.Request.Query.TryGetValue(_options.QueryParameterName, out var values)) + if (!context.Request.Query.TryGetValue(_options.SessionResolution.QueryParameterName, out var values)) return null; var raw = values.FirstOrDefault(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponseWriter.cs new file mode 100644 index 00000000..837a3430 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponseWriter.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class DefaultRefreshResponseWriter : IRefreshResponseWriter + { + private readonly UAuthDiagnosticsOptions _diagnostics; + + public DefaultRefreshResponseWriter(IOptions options) + { + _diagnostics = options.Value.Diagnostics; + } + + public void Write(HttpContext context, RefreshOutcome outcome) + { + if (!_diagnostics.EnableRefreshHeaders) + return; + + context.Response.Headers["X-UAuth-Refresh"] = outcome switch + { + RefreshOutcome.NoOp => "no-op", + RefreshOutcome.Touched => "touched", + RefreshOutcome.ReauthRequired => "reauth-required", + _ => "unknown" + }; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs new file mode 100644 index 00000000..93e6dbd8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs @@ -0,0 +1,56 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + /// + /// + /// + /// + public sealed class DefaultSessionRefreshService : ISessionRefreshService where TUserId : notnull + { + private readonly UAuthServerOptions _options; + private readonly ISessionStore _store; + private readonly ISessionActivityWriter _activityWriter; + + public DefaultSessionRefreshService( + IOptions options, + ISessionStore store, + ISessionActivityWriter activityWriter) + { + _options = options.Value; + _store = store; + _activityWriter = activityWriter; + } + + // 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, DateTimeOffset now, CancellationToken ct = default) + { + if (!validation.IsValid) + return SessionRefreshResult.Failed(); + + var session = validation.Session; + bool didTouch = false; + var touchInterval = _options.Session.TouchInterval; + + if (touchInterval.HasValue) + { + var elapsed = now - session.LastSeenAt; + + if (elapsed >= touchInterval.Value) + { + var touched = session.Touch(now); + await _activityWriter.TouchAsync(validation.TenantId, touched, ct); + didTouch = true; + } + } + + var primaryToken = PrimaryToken.FromSession(session.SessionId); + // For PureOpaque sessions, we do not issue a new refresh token on refresh. + return SessionRefreshResult.Success(primaryToken, didTouch: didTouch); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponseWriter.cs new file mode 100644 index 00000000..551fc0c1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponseWriter.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface IRefreshResponseWriter + { + void Write(HttpContext context, RefreshOutcome outcome); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshService.cs new file mode 100644 index 00000000..b4f85e26 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshService.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + /// + /// Base contract for refresh-related services. + /// Refresh services renew authentication artifacts according to AuthMode. + /// + public interface IRefreshService + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs new file mode 100644 index 00000000..e7e7c2bf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + /// + /// Refreshes session lifecycle artifacts. + /// Used by PureOpaque and Hybrid modes. + /// + public interface ISessionRefreshService : IRefreshService where TUserId : notnull + { + Task RefreshAsync(SessionValidationResult validation, DateTimeOffset now, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs new file mode 100644 index 00000000..7001b549 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs @@ -0,0 +1,33 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + /// + /// 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 is not supported for this mode. + /// + NotSupported = 0, + + /// + /// Only session lifecycle can be refreshed. + /// (PureOpaque) + /// + SessionOnly = 1, + + /// + /// Session lifecycle + token issuance can be refreshed. + /// (Hybrid) + /// + SessionAndToken = 2, + + /// + /// Only token lifecycle can be refreshed. + /// (SemiHybrid, PureJwt) + /// + TokenOnly = 3 + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs new file mode 100644 index 00000000..ae227cf4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + /// + /// Resolves refresh behavior based on AuthMode and server options. + /// This class is the single source of truth for refresh capability. + /// + public static class RefreshDecisionResolver + { + public static RefreshDecision Resolve(UAuthServerOptions options) + { + return options.Mode switch + { + UAuthMode.PureOpaque => RefreshDecision.SessionOnly, + UAuthMode.Hybrid => RefreshDecision.SessionAndToken, + UAuthMode.SemiHybrid => RefreshDecision.TokenOnly, + UAuthMode.PureJwt => RefreshDecision.TokenOnly, + + _ => RefreshDecision.NotSupported + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshEvaluationResult.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshEvaluationResult.cs new file mode 100644 index 00000000..f22aa1b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshEvaluationResult.cs @@ -0,0 +1,6 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed record RefreshEvaluationResult(RefreshOutcome Outcome); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/DefaultSessionContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/DefaultSessionContextAccessor.cs new file mode 100644 index 00000000..b5b02b64 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/DefaultSessionContextAccessor.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure.Session +{ + public sealed class DefaultSessionContextAccessor : ISessionContextAccessor + { + private readonly IHttpContextAccessor _httpContextAccessor; + + public DefaultSessionContextAccessor(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public SessionContext? Current + { + get + { + var ctx = _httpContextAccessor.HttpContext; + if (ctx is null) + return null; + + if (ctx.Items.TryGetValue(SessionContextItemKeys.SessionContext, out var value)) + return value as SessionContext; + + return null; + } + } + } +} 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..ac69fc9f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs @@ -0,0 +1,12 @@ +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/SessionContextItemKeys.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs new file mode 100644 index 00000000..b028fdc1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal static class SessionContextItemKeys + { + public const string SessionContext = "__UAuth.SessionContext"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs index 6891d6d0..d4233792 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs @@ -2,13 +2,11 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Domain.Session; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; -using System.Net.Http; using System.Security; namespace CodeBeam.UltimateAuth.Server.Issuers @@ -18,9 +16,9 @@ public sealed class UAuthSessionIssuer : IHttpSessionIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly ISessionStoreFactory _storeFactory; private readonly UAuthServerOptions _options; - private readonly IUAuthSessionCookieManager _cookieManager; + private readonly IUAuthCookieManager _cookieManager; - public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, ISessionStoreFactory storeFactory, IOptions options, IUAuthSessionCookieManager cookieManager) + public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, ISessionStoreFactory storeFactory, IOptions options, IUAuthCookieManager cookieManager) { _opaqueGenerator = opaqueGenerator; _storeFactory = storeFactory; @@ -212,10 +210,10 @@ await store.RevokeSessionAsync( }; }); - if (httpContext is not null) - { - _cookieManager.Issue(httpContext, issued!.OpaqueSessionId); - } + //if (httpContext is not null) + //{ + // _cookieManager.Write(httpContext, issued!.OpaqueSessionId); + //} return issued!; } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index bb4a5ffd..cbd53bc5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -10,8 +10,6 @@ public sealed class SessionResolutionMiddleware { private readonly RequestDelegate _next; - public const string SessionContextKey = "__UAuthSession"; - public SessionResolutionMiddleware(RequestDelegate next) { _next = next; @@ -30,7 +28,7 @@ public async Task InvokeAsync(HttpContext context) sessionId.Value, tenant.TenantId); - context.Items[SessionContextKey] = sessionContext; + context.Items[SessionContextItemKeys.SessionContext] = sessionContext; await _next(context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs new file mode 100644 index 00000000..55ab9e21 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class AuthResponseOptions + { + 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(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/ConfigureDefaults.cs deleted file mode 100644 index 8c403f29..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Options/ConfigureDefaults.cs +++ /dev/null @@ -1,122 +0,0 @@ -using CodeBeam.UltimateAuth.Core; - -namespace CodeBeam.UltimateAuth.Server.Options -{ - internal class ConfigureDefaults - { - internal static void ApplyClientProfileDefaults(UAuthServerOptions o) - { - if (o.ClientProfile == UAuthClientProfile.NotSpecified) - { - o.Mode ??= UAuthMode.Hybrid; - return; - } - - if (o.Mode is null) - { - o.Mode = o.ClientProfile switch - { - UAuthClientProfile.BlazorServer => UAuthMode.PureOpaque, - UAuthClientProfile.BlazorWasm => UAuthMode.SemiHybrid, - UAuthClientProfile.Maui => UAuthMode.SemiHybrid, - UAuthClientProfile.Mvc => UAuthMode.Hybrid, - UAuthClientProfile.Api => UAuthMode.PureJwt, - _ => throw new InvalidOperationException("Unsupported client profile. Please specify a client profile or make sure it's set NotSpecified") - }; - } - - if (o.HubDeploymentMode == default) - { - o.HubDeploymentMode = o.ClientProfile switch - { - UAuthClientProfile.BlazorWasm => UAuthHubDeploymentMode.Integrated, - UAuthClientProfile.Maui => UAuthHubDeploymentMode.Integrated, - _ => UAuthHubDeploymentMode.Embedded - }; - } - } - - internal static void ApplyModeDefaults(UAuthServerOptions o) - { - switch (o.Mode) - { - 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: {o.Mode}"); - } - } - - private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) - { - var s = o.Session; - var t = o.Tokens; - - s.SlidingExpiration = true; - s.IdleTimeout ??= TimeSpan.FromHours(1); - s.MaxLifetime ??= TimeSpan.FromDays(7); - - t.IssueJwt = false; - t.IssueOpaque = false; - } - - private static void ApplyHybridDefaults(UAuthServerOptions o) - { - var s = o.Session; - var t = o.Tokens; - - s.SlidingExpiration = true; - - t.IssueJwt = true; - t.IssueOpaque = true; - t.AccessTokenLifetime = TimeSpan.FromMinutes(10); - t.RefreshTokenLifetime = TimeSpan.FromDays(7); - } - - private static void ApplySemiHybridDefaults(UAuthServerOptions o) - { - var s = o.Session; - var t = o.Tokens; - var p = o.Pkce; - - s.SlidingExpiration = false; - - t.IssueJwt = true; - t.IssueOpaque = true; - t.AccessTokenLifetime = TimeSpan.FromMinutes(10); - t.RefreshTokenLifetime = TimeSpan.FromDays(7); - t.AddJwtIdClaim = true; - } - - private static void ApplyPureJwtDefaults(UAuthServerOptions o) - { - var t = o.Tokens; - var p = o.Pkce; - - 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; - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs new file mode 100644 index 00000000..dc16419d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class CredentialResponseOptions + { + 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; + } +} 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..76e0ed16 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -0,0 +1,223 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + internal class ConfigureDefaults + { + internal static void ApplyClientProfileDefaults(UAuthServerOptions o, UAuthOptions core) + { + if (core.ClientProfile == UAuthClientProfile.NotSpecified) + { + o.Mode ??= UAuthMode.Hybrid; + return; + } + + if (o.Mode is null) + { + o.Mode = core.ClientProfile switch + { + UAuthClientProfile.BlazorServer => UAuthMode.PureOpaque, + UAuthClientProfile.BlazorWasm => UAuthMode.SemiHybrid, + UAuthClientProfile.Maui => UAuthMode.SemiHybrid, + UAuthClientProfile.Mvc => UAuthMode.Hybrid, + UAuthClientProfile.Api => UAuthMode.PureJwt, + _ => throw new InvalidOperationException("Unsupported client profile. Please specify a client profile or make sure it's set NotSpecified") + }; + } + + if (o.HubDeploymentMode == default) + { + o.HubDeploymentMode = core.ClientProfile switch + { + UAuthClientProfile.BlazorWasm => UAuthHubDeploymentMode.Integrated, + UAuthClientProfile.Maui => UAuthHubDeploymentMode.Integrated, + _ => UAuthHubDeploymentMode.Embedded + }; + } + } + + internal static void ApplyModeDefaults(UAuthServerOptions o) + { + switch (o.Mode) + { + 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: {o.Mode}"); + } + } + + internal static void ApplyAuthResponseDefaults(UAuthServerOptions o, UAuthOptions core) + { + var ar = o.AuthResponse; + if (ar is null) + return; + + bool sessionNotSet = ar.SessionIdDelivery.Mode == TokenResponseMode.None; + bool accessNotSet = ar.AccessTokenDelivery.Mode == TokenResponseMode.None; + bool refreshNotSet = ar.RefreshTokenDelivery.Mode == TokenResponseMode.None; + + if (!sessionNotSet || !accessNotSet || !refreshNotSet) + return; + + switch (core.ClientProfile) + { + // TODO: Change NotSpecified option defaults. Should be same as BlazorWasm. + case UAuthClientProfile.NotSpecified: + ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; + ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; + ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.None }; + ar.Login.RedirectEnabled = true; + ar.Logout.RedirectEnabled = true; + break; + case UAuthClientProfile.BlazorServer: + ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; + ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; + ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.None }; + ar.Login.RedirectEnabled = true; + ar.Logout.RedirectEnabled = true; + break; + + case UAuthClientProfile.BlazorWasm: + ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; + ar.Login.RedirectEnabled = true; + ar.Logout.RedirectEnabled = true; + break; + + case UAuthClientProfile.Maui: + ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.Login.RedirectEnabled = true; + ar.Logout.RedirectEnabled = true; + break; + + case UAuthClientProfile.Mvc: + ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; + ar.Login.RedirectEnabled = true; + ar.Logout.RedirectEnabled = true; + break; + + case UAuthClientProfile.Api: + ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; + ar.Login.RedirectEnabled = false; + ar.Logout.RedirectEnabled = false; + break; + } + } + + private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + var c = o.Cookie; + + // 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.IdleBuffer = TimeSpan.FromDays(2); + } + + private static void ApplyHybridDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + var c = o.Cookie; + + s.SlidingExpiration = true; + s.TouchInterval = null; + + t.IssueJwt = true; + t.IssueOpaque = true; + t.AccessTokenLifetime = TimeSpan.FromMinutes(10); + t.RefreshTokenLifetime = TimeSpan.FromDays(7); + + c.IdleBuffer = TimeSpan.FromMinutes(5); + } + + private static void ApplySemiHybridDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + 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.IdleBuffer = TimeSpan.FromMinutes(5); + } + + private static void ApplyPureJwtDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + 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.IdleBuffer = TimeSpan.FromSeconds(30); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs new file mode 100644 index 00000000..7db1913d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class LoginRedirectOptions + { + public bool RedirectEnabled { get; set; } = true; + + public string SuccessRedirect { get; init; } = "/"; + public string FailureRedirect { get; init; } = "/login"; + + public string FailureQueryKey { get; init; } = "error"; + public string CodeQueryKey { get; set; } = "code"; + + public Dictionary FailureCodes { get; set; } = new(); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs new file mode 100644 index 00000000..43756404 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs @@ -0,0 +1,21 @@ +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; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs new file mode 100644 index 00000000..d28e7ffe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class PrimaryCredentialPolicy + { + /// + /// Default primary credential for UI-style requests. + /// + public PrimaryCredentialKind Ui { get; set; } = PrimaryCredentialKind.Stateful; + + /// + /// Default primary credential for API requests. + /// + public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs index e7728fae..d0eb9b08 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs @@ -15,4 +15,22 @@ public sealed class UAuthCookieOptions public CookieSecurePolicy SecurePolicy { get; set; } = CookieSecurePolicy.Always; internal SameSiteMode? SameSiteOverride { get; set; } + + /// + /// Cookie path. Default is "/". + /// + public string Path { get; set; } = "/"; + + /// + /// If set, defines absolute expiration for the cookie. + /// If null, a session cookie is used. + /// + public TimeSpan? MaxAge { get; set; } + + /// + /// Additional tolerance added to session idle timeout + /// when resolving cookie lifetime. + /// Default: 5 minutes. + /// + public TimeSpan IdleBuffer { get; set; } = TimeSpan.FromMinutes(5); } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs new file mode 100644 index 00000000..08a8f754 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class UAuthDiagnosticsOptions + { + /// + /// Enables debug / sample-only response headers such as X-UAuth-Refresh. + /// Should be disabled in production. + /// + public bool EnableRefreshHeaders { get; set; } = false; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 8bbb703c..78218f99 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -14,8 +14,6 @@ namespace CodeBeam.UltimateAuth.Server.Options /// public sealed class UAuthServerOptions { - public UAuthClientProfile ClientProfile { get; set; } - /// /// Defines how UltimateAuth executes authentication flows. /// Default is Hybrid. @@ -75,9 +73,11 @@ public sealed class UAuthServerOptions /// public UAuthCookieOptions Cookie { get; } = new(); + public UAuthDiagnosticsOptions Diagnostics { get; set; } = new(); + internal Type? CustomCookieManagerType { get; private set; } - public void ReplaceSessionCookieManager() where T : class, IUAuthSessionCookieManager + public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManager { CustomCookieManagerType = typeof(T); } @@ -86,6 +86,16 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthSessionCooki // SERVER-ONLY BEHAVIOR // ------------------------------------------------------- + public PrimaryCredentialPolicy PrimaryCredential { get; init; } = new(); + + public AuthResponseOptions AuthResponse { get; init; } = new(); + + /// + /// Controls how session identifiers are resolved from incoming requests + /// (cookie, header, bearer, query, order, etc.) + /// + public UAuthSessionResolutionOptions SessionResolution { get; } = new(); + /// /// Enables/disables specific endpoint groups. /// Useful for API hardening. diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs new file mode 100644 index 00000000..fcd717c9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + internal sealed class UAuthServerProfileDetector : IServerProfileDetector + { + public UAuthClientProfile Detect(IServiceProvider sp) + { + 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; + } + + if (sp.GetService() is not null) + return UAuthClientProfile.Mvc; + + return UAuthClientProfile.NotSpecified; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs index 9bbac33a..2370aebd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs @@ -1,5 +1,7 @@ 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; @@ -8,7 +10,6 @@ public sealed class UAuthSessionResolutionOptions public bool EnableQuery { get; set; } = false; public string HeaderName { get; set; } = "X-UAuth-Session"; - public string CookieName { get; set; } = "__uauth"; public string QueryParameterName { get; set; } = "session_id"; // Precedence order diff --git a/src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs b/src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs new file mode 100644 index 00000000..a24437f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Runtime +{ + public sealed class UAuthServerProductInfo + { + public string ProductName { get; init; } = "UltimateAuthServer"; + public UAuthProductInfo Core { get; init; } = default!; + + public UAuthMode? AuthMode { get; init; } + public UAuthHubDeploymentMode HubDeploymentMode { get; init; } + + public bool PkceEnabled { get; init; } + public bool RefreshEnabled { get; init; } + public bool MultiTenancyEnabled { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index f70c648e..f23c8005 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -248,7 +248,9 @@ await _orchestrator.ExecuteAsync( var accessToken = await _tokens.IssueAccessTokenAsync(tokenContext, ct); var refreshToken = await _tokens.IssueRefreshTokenAsync(tokenContext, ct); - return SessionRefreshResult.Success(accessToken, refreshToken); + var primaryToken = PrimaryToken.FromAccessToken(accessToken); + + return SessionRefreshResult.Success(primaryToken, refreshToken); } public Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj b/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj index 8004a0dd..004336af 100644 --- a/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj +++ b/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj @@ -5,6 +5,7 @@ enable enable true + $(NoWarn);1591 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 index 63871f24..a34b1a73 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj @@ -4,6 +4,7 @@ net8.0;net9.0;net10.0 enable enable + 0.0.1-preview true 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 index b81bbdc9..a3e1cf08 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj @@ -4,6 +4,7 @@ net8.0;net9.0;net10.0 enable enable + 0.0.1-preview true 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 index 49bc1947..10d29026 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj @@ -5,6 +5,7 @@ enable enable true + $(NoWarn);1591 diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs new file mode 100644 index 00000000..cd7fe8b9 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory +{ + internal sealed class InMemorySessionActivityWriter : ISessionActivityWriter where TUserId : notnull + { + private readonly ISessionStoreFactory _factory; + + public InMemorySessionActivityWriter(ISessionStoreFactory factory) + { + _factory = factory; + } + + public Task TouchAsync(string? tenantId, ISession session, CancellationToken ct) + { + var kernel = _factory.Create(tenantId); + return kernel.SaveSessionAsync(tenantId, session); + } + } + +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index 08b5e523..24e1e564 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -1,7 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Domain.Session; using System.Security; namespace CodeBeam.UltimateAuth.Sessions.InMemory; diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs index c9c0fe0a..70af9b5a 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCo { services.AddSingleton(); services.AddScoped(typeof(ISessionStore<>), typeof(InMemorySessionStore<>)); + services.AddScoped(typeof(ISessionActivityWriter<>), typeof(InMemorySessionActivityWriter<>)); return services; } } 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 index 63871f24..1f3e2def 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj @@ -5,6 +5,7 @@ enable enable true + $(NoWarn);1591 From 60c02d94545106bc04b09d6e9bf2be43446888e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:55:10 +0300 Subject: [PATCH 14/50] Refactor CI workflow for UltimateAuth --- .github/workflows/ultimateauth-ci.yml | 31 +++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ultimateauth-ci.yml b/.github/workflows/ultimateauth-ci.yml index 17a24e61..021000ae 100644 --- a/.github/workflows/ultimateauth-ci.yml +++ b/.github/workflows/ultimateauth-ci.yml @@ -1,19 +1,19 @@ name: UltimateAuth CI on: - pull_request: - branches: - - dev push: - branches: - - dev + branches: [ dev ] + pull_request: + branches: [ dev ] + workflow_dispatch: jobs: - build-and-test: - name: Build & Test + 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'] @@ -32,12 +32,15 @@ jobs: - name: 🏗️ Build run: dotnet build --configuration Release --no-restore - - name: 🧪 Run tests - run: dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" + - name: 🧪 Test with coverage + run: | + dotnet test \ + --configuration Release \ + --no-build \ + --collect:"XPlat Code Coverage" - - name: ⬆ Upload code coverage - uses: actions/upload-artifact@v4 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 with: - name: code-coverage - path: '**/coverage.cobertura.xml' - if-no-files-found: ignore + token: ${{ secrets.CODECOV_TOKEN }} + slug: CodeBeamOrg/UltimateAuth From 17d52759fcf447c5c73a8fd341407ef32a690ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:02:36 +0300 Subject: [PATCH 15/50] Add Codecov configuration file --- .github/codecov.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/codecov.yml diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 00000000..72a0fa4e --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,13 @@ +comment: + require_changes: yes + +coverage: + status: + project: + default: + target: 0% + threshold: 0% + patch: + default: + target: 0% + threshold: 0% From 78187b12c1d3207290375811106559043132e5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:22:26 +0300 Subject: [PATCH 16/50] Revise project status and add status badges Updated project status message and added badges. --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 22761bcc..d2a3c9e1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ -⚠️ This project is in early development. Preview release expected Q1 2026. +⚠️ This project is in development. First preview release expected Q1 2026 - coming soon. # UltimateAuth ### The Modern Unified Auth Framework for .NET -- Reimagined. A CodeBeam Project +![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) +[![codecov](https://codecov.io/gh/CodeBeamOrg/UltimateAuth/branch/dev/graph/badge.svg)](https://codecov.io/gh/CodeBeamOrg/UltimateAuth) + + --- 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. From 882ce390350b982d8f8fb5e7513f55df36dfc262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:51:47 +0300 Subject: [PATCH 17/50] Preparation of First Release (Part 1) (#9) * Preparation of First Release (Part 1) * Create Test Project --- UltimateAuth.slnx | 3 + .../Components/Pages/Counter.razor | 18 ------ .../Components/Pages/Weather.razor | 63 ------------------- .../CodeBeam.UltimateAuth.Core.Tests.csproj | 26 ++++++++ .../UnitTest1.cs | 11 ++++ 5 files changed, 40 insertions(+), 81 deletions(-) delete mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Counter.razor delete mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Weather.razor create mode 100644 tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj create mode 100644 tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 3ba93311..8767d52e 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -7,6 +7,9 @@ + + + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Counter.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Counter.razor deleted file mode 100644 index ef23cb31..00000000 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Counter.razor +++ /dev/null @@ -1,18 +0,0 @@ -@page "/counter" - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Weather.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Weather.razor deleted file mode 100644 index 8eca4cc4..00000000 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Weather.razor +++ /dev/null @@ -1,63 +0,0 @@ -@page "/weather" - -Weather - -

Weather

- -

This component demonstrates showing data.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@code { - private WeatherForecast[]? forecasts; - - protected override async Task OnInitializedAsync() - { - // Simulate asynchronous loading to demonstrate a loading indicator - await Task.Delay(500); - - var startDate = DateOnly.FromDateTime(DateTime.Now); - var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; - forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = startDate.AddDays(index), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }).ToArray(); - } - - private class WeatherForecast - { - public DateOnly Date { get; set; } - public int TemperatureC { get; set; } - public string? Summary { get; set; } - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - } -} diff --git a/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj b/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj new file mode 100644 index 00000000..88d7cd41 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs b/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs new file mode 100644 index 00000000..acaced2a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Core.Tests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} From 4f5bcf75664ae165c5456204e392de0c2630c131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Tue, 30 Dec 2025 21:46:59 +0300 Subject: [PATCH 18/50] Preparation of First Release v 0.0.1 (Part 2) (#10) * Preparation of First Release v 0.0.1 (Part 2) * Add Project References To Test Project * Add CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore project & Implementation * Add CodeBeam.UltimateAuth.Tokens.EntityFramework project & Implementation & First Unit Tests * Add CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore & Implementation --- UltimateAuth.slnx | 5 +- .../Abstractions/Stores/ISessionStore.cs | 28 +- .../AssemblyVisibility.cs | 3 + .../Contracts/Token/RefreshToken.cs | 3 +- .../Domain/Session/AuthSessionId.cs | 2 + .../Domain/Session/ChainId.cs | 2 + .../Domain/Session/DeviceInfo.cs | 13 + .../Domain/Session/ISessionChain.cs | 2 + .../Domain/Session/UAuthSession.cs | 32 ++ .../Domain/Session/UAuthSessionChain.cs | 24 ++ .../Domain/Session/UAuthSessionRoot.cs | 21 + .../Domain/Token/SessionRefreshStatus.cs | 2 +- .../Abstractions/CredentialUserMapping.cs | 10 + .../AssemblyVisibility.cs | 3 + ...uth.Credentials.EntityFrameworkCore.csproj | 28 ++ .../Configuration/ConventionResolver.cs | 24 ++ .../CredentialUserMappingBuilder.cs | 75 ++++ .../CredentialUserMappingOptions.cs | 29 ++ .../EfCoreAuthUser.cs | 16 + .../Infrastructure/EfCoreUserStore.cs | 83 ++++ .../ServiceCollectionExtensions.cs | 19 + ...teAuth.Sessions.EntityFrameworkCore.csproj | 28 ++ .../EfCoreSessionActivityWriter.cs | 33 ++ .../EfCoreSessionStore.cs | 360 ++++++++++++++++++ .../EfCoreSessionStoreKernel.cs | 47 +++ .../SessionChainProjection.cs | 26 ++ .../EntityProjections/SessionProjection.cs | 31 ++ .../SessionRootProjection.cs | 18 + .../JsonValueConverter.cs | 15 + .../Mappers/SessionChainProjectionMapper.cs | 42 ++ .../Mappers/SessionProjectionMapper.cs | 54 +++ .../Mappers/SessionRootProjectionMapper.cs | 36 ++ .../NullableAuthSessionIdConverter.cs | 15 + .../ServiceCollectionExtensions.cs | 18 + .../UAuthSessionDbContext.cs | 104 +++++ .../InMemorySessionStore.cs | 28 +- ...mateAuth.Tokens.EntityFrameworkCore.csproj | 28 ++ .../EfCoreTokenStore.cs | 174 +++++++++ .../EfCoreTokenStoreKernel.cs | 69 ++++ .../Projections/RefreshTokenProjection.cs | 20 + .../Projections/RevokedIdTokenProjection.cs | 14 + .../ServiceCollectionExtensions.cs | 17 + .../UAuthTokenDbContext.cs | 71 ++++ ...deBeam.UltimateAuth.Tokens.InMemory.csproj | 2 +- .../InMemoryTokenStore.cs | 40 +- .../CodeBeam.UltimateAuth.Core.Tests.csproj | 26 -- .../UnitTest1.cs | 11 - .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 36 ++ .../Core/AuthSessionIdTests.cs | 22 ++ .../Core/UAuthSessionChainTests.cs | 40 ++ .../Core/UAuthSessionTests.cs | 52 +++ .../CredentialUserMappingBuilderTests.cs | 95 +++++ 52 files changed, 1877 insertions(+), 119 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/AssemblyVisibility.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs delete mode 100644 tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj delete mode 100644 tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 8767d52e..9f87acca 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -8,14 +8,17 @@ - + + + + diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 8126aca8..902b9051 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -12,47 +12,31 @@ public interface ISessionStore /// /// Retrieves an active session by id. /// - Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId); + Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); /// /// Creates a new session and associates it with the appropriate chain and root. /// - Task CreateSessionAsync( - IssuedSession issuedSession, - SessionStoreContext context); + Task CreateSessionAsync(IssuedSession issuedSession, SessionStoreContext context, CancellationToken ct = default); /// /// Refreshes (rotates) the active session within its chain. /// - Task RotateSessionAsync( - AuthSessionId currentSessionId, - IssuedSession newSession, - SessionStoreContext context); + Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession newSession, SessionStoreContext context, CancellationToken ct = default); /// /// Revokes a single session. /// - Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset at); + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default); /// /// Revokes all sessions for a specific user (all devices). /// - Task RevokeAllSessionsAsync( - string? tenantId, - TUserId userId, - DateTimeOffset at); + Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default); /// /// Revokes all sessions within a specific chain (single device). /// - Task RevokeChainAsync( - string? tenantId, - ChainId chainId, - DateTimeOffset at); + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, 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..07826e24 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore")] diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs index 1e9d87a0..54306e68 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs @@ -1,8 +1,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { /// - /// Represents an issued refresh token. - /// Always opaque and hashed at rest. + /// Transport model for refresh token. Returned to client once upon creation. /// public sealed class RefreshToken { diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index 121f389c..e6261ca0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -32,6 +32,8 @@ public static bool TryCreate(string raw, out AuthSessionId sessionId) ///
public string Value { get; } + public static AuthSessionId From(string value) => new(value); + /// /// Determines whether the specified is equal to the current instance. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs index 292898b6..486c80cd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs @@ -27,6 +27,8 @@ public ChainId(Guid value) /// A new instance. public static ChainId New() => new ChainId(Guid.NewGuid()); + public static ChainId From(Guid value) => new(value); + /// /// Determines whether the specified is equal to the current instance. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs index c70c7e5e..969b474b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs @@ -7,8 +7,10 @@ ///
public sealed class DeviceInfo { + // TODO: Implement DeviceId and makes it first-class citizen in security policies. /// /// Gets the unique identifier for the device. + /// No session should be created without a device id. /// public string DeviceId { get; init; } = default!; @@ -72,6 +74,17 @@ public sealed class DeviceInfo IsTrusted = null }; + // TODO: Empty may not be good approach, make strict security here + public static DeviceInfo Empty { get; } = new() + { + DeviceId = "", + Platform = null, + Browser = null, + IpAddress = null, + UserAgent = null, + IsTrusted = null + }; + /// /// Determines whether the current device information matches the specified device information based on device /// identifiers. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs index da2b8d89..c7faf4a7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs @@ -12,6 +12,8 @@ public interface ISessionChain /// ChainId ChainId { get; } + string? TenantId { get; } + /// /// Gets the identifier of the user who owns this chain. /// Each chain represents one device/login family for this user. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 1de4b275..154aeafd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -136,6 +136,38 @@ public ISession Revoke(DateTimeOffset at) ); } + internal static UAuthSession FromProjection( + AuthSessionId sessionId, + string? tenantId, + TUserId userId, + ChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + DateTimeOffset? lastSeenAt, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + DeviceInfo device, + ClaimsSnapshot claims, + SessionMetadata metadata) + { + return new UAuthSession( + sessionId, + tenantId, + userId, + chainId, + createdAt, + expiresAt, + lastSeenAt, + isRevoked, + revokedAt, + securityVersionAtCreation, + device, + claims, + metadata + ); + } + public SessionState GetState(DateTimeOffset at) { if (IsRevoked) return SessionState.Revoked; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 70849ea2..91ecbce1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -108,5 +108,29 @@ public ISessionChain Revoke(DateTimeOffset at) ); } + internal static UAuthSessionChain FromProjection( + ChainId chainId, + string? tenantId, + TUserId userId, + int rotationCount, + long securityVersionAtCreation, + ClaimsSnapshot claimsSnapshot, + AuthSessionId? activeSessionId, + bool isRevoked, + DateTimeOffset? revokedAt) + { + return new UAuthSessionChain( + chainId, + tenantId, + userId, + rotationCount, + securityVersionAtCreation, + claimsSnapshot, + activeSessionId, + isRevoked, + revokedAt + ); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 1f6641f5..ae41b27a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -76,5 +76,26 @@ public ISessionRoot AttachChain(ISessionChain chain, DateTimeO ); } + internal static UAuthSessionRoot FromProjection( + string? tenantId, + TUserId userId, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersion, + IReadOnlyList> chains, + DateTimeOffset lastUpdatedAt) + { + return new UAuthSessionRoot( + tenantId, + userId, + isRevoked, + revokedAt, + securityVersion, + chains, + lastUpdatedAt + ); + } + + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs index 64e132d5..d8724ba1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs @@ -5,6 +5,6 @@ public enum SessionRefreshStatus Success, ReauthRequired, InvalidRequest, - Failed = 3 + Failed } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs new file mode 100644 index 00000000..5b8773ff --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class CredentialUserMapping +{ + public Func UserId { get; init; } = default!; + public Func Username { get; init; } = default!; + public Func PasswordHash { get; init; } = default!; + public Func SecurityVersion { get; init; } = default!; + public Func CanAuthenticate { get; init; } = default!; +} 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..e73e9e50 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs new file mode 100644 index 00000000..4b2c9f2e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs @@ -0,0 +1,24 @@ +using System.Linq.Expressions; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +{ + internal static class ConventionResolver + { + public static Expression>? TryResolve(params string[] names) + { + var prop = typeof(TUser) + .GetProperties() + .FirstOrDefault(p => + names.Contains(p.Name, StringComparer.OrdinalIgnoreCase) && + typeof(TProp).IsAssignableFrom(p.PropertyType)); + + if (prop is null) + return null; + + var param = Expression.Parameter(typeof(TUser), "u"); + var body = Expression.Property(param, prop); + + return Expression.Lambda>(body, param); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs new file mode 100644 index 00000000..dfb653c7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs @@ -0,0 +1,75 @@ +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +{ + internal static class CredentialUserMappingBuilder + { + public static CredentialUserMapping Build(CredentialUserMappingOptions options) + { + if (options.UserId is null) + { + var expr = ConventionResolver.TryResolve("Id", "UserId"); + if (expr != null) + options.ApplyUserId(expr); + } + + if (options.Username is null) + { + var expr = ConventionResolver.TryResolve( + "Username", + "UserName", + "Email", + "EmailAddress", + "Login"); + + if (expr != null) + options.ApplyUsername(expr); + } + + // Never add "Password" as a convention to avoid accidental mapping to plaintext password properties + if (options.PasswordHash is null) + { + var expr = ConventionResolver.TryResolve( + "PasswordHash", + "Passwordhash", + "PasswordHashV2"); + + if (expr != null) + options.ApplyPasswordHash(expr); + } + + if (options.SecurityVersion is null) + { + var expr = ConventionResolver.TryResolve( + "SecurityVersion", + "SecurityStamp", + "AuthVersion"); + + if (expr != null) + options.ApplySecurityVersion(expr); + } + + + if (options.UserId is null) + throw new InvalidOperationException("UserId mapping is required. Use MapUserId(...) or ensure a conventional property exists."); + + if (options.Username is null) + throw new InvalidOperationException("Username mapping is required. Use MapUsername(...) or ensure a conventional property exists."); + + if (options.PasswordHash is null) + throw new InvalidOperationException("PasswordHash mapping is required. Use MapPasswordHash(...) or ensure a conventional property exists."); + + if (options.SecurityVersion is null) + throw new InvalidOperationException("SecurityVersion mapping is required. Use MapSecurityVersion(...) or ensure a conventional property exists."); + + var canAuthenticateExpr = options.CanAuthenticate ?? (_ => true); + + return new CredentialUserMapping + { + UserId = options.UserId.Compile(), + Username = options.Username.Compile(), + PasswordHash = options.PasswordHash.Compile(), + SecurityVersion = options.SecurityVersion.Compile(), + CanAuthenticate = canAuthenticateExpr.Compile() + }; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs new file mode 100644 index 00000000..b8c326fc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public sealed class CredentialUserMappingOptions +{ + internal Expression>? UserId { get; private set; } + internal Expression>? Username { get; private set; } + internal Expression>? PasswordHash { get; private set; } + internal Expression>? SecurityVersion { get; private set; } + internal Expression>? CanAuthenticate { get; private set; } + + public void MapUserId(Expression> expr) => UserId = expr; + public void MapUsername(Expression> expr) => Username = expr; + public void MapPasswordHash(Expression> expr) => PasswordHash = expr; + public void MapSecurityVersion(Expression> expr) => SecurityVersion = expr; + + /// + /// Optional. If not specified, all users are allowed to authenticate. + /// Use this to enforce custom user state rules (e.g. Active, Locked, Suspended). + /// Users that can't authenticate don't show up in authentication results. + /// + public void MapCanAuthenticate(Expression> expr) => CanAuthenticate = expr; + + internal void ApplyUserId(Expression> expr) => UserId = expr; + internal void ApplyUsername(Expression> expr) => Username = expr; + internal void ApplyPasswordHash(Expression> expr) => PasswordHash = expr; + internal void ApplySecurityVersion(Expression> expr) => SecurityVersion = expr; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs new file mode 100644 index 00000000..382cb1f2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +{ + internal sealed class EfCoreAuthUser : IUser + { + public TUserId UserId { get; } + + IReadOnlyDictionary? IUser.Claims => null; + + public EfCoreAuthUser(TUserId userId) + { + UserId = userId; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs new file mode 100644 index 00000000..8a6fb28f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs @@ -0,0 +1,83 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCoreUserStore : IUAuthUserStore where TUser : class +{ + private readonly DbContext _db; + private readonly CredentialUserMapping _map; + + public EfCoreUserStore(DbContext db, IOptions> options) + { + _db = db; + _map = CredentialUserMappingBuilder.Build(options.Value); + } + + public async Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); + + if (user is null || !_map.CanAuthenticate(user)) + return null; + + return new EfCoreAuthUser(_map.UserId(user)); + } + + public async Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default) + { + var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == username, ct); + + if (user is null || !_map.CanAuthenticate(user)) + return null; + + return new UserRecord + { + Id = _map.UserId(user), + Username = _map.Username(user), + PasswordHash = _map.PasswordHash(user), + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow, + IsDeleted = false + }; + } + + public async Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default) + { + var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == login, ct); + + if (user is null || !_map.CanAuthenticate(user)) + return null; + + return new EfCoreAuthUser(_map.UserId(user)); + } + + public Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + return _db.Set() + .Where(u => _map.UserId(u)!.Equals(userId)) + .Select(u => _map.PasswordHash(u)) + .FirstOrDefaultAsync(ct); + } + + public Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default) + { + throw new NotSupportedException("Password updates are not supported by EfCoreUserStore. " + + "Use application-level user management services."); + } + + public async Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); + return user is null ? 0 : _map.SecurityVersion(user); + } + + public Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default) + { + throw new NotSupportedException("Security version updates must be handled by the application."); + } + +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..3f7e4f1f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEfCoreCredentials( + this IServiceCollection services, + Action> configure) + where TUser : class + { + services.Configure(configure); + + services.AddScoped, EfCoreUserStore>(); + + return services; + } +} 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..e656299b --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs new file mode 100644 index 00000000..c998acd3 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class EfCoreSessionActivityWriter : ISessionActivityWriter where TUserId : notnull +{ + private readonly UltimateAuthSessionDbContext _db; + + public EfCoreSessionActivityWriter(UltimateAuthSessionDbContext db) + { + _db = db; + } + + public async Task TouchAsync(string? tenantId, ISession session, CancellationToken ct) + { + var projection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == session.SessionId && + x.TenantId == tenantId, + ct); + + if (projection is null) + return; + // TODO: Rethink architecture + var updated = session as UAuthSession + ?? throw new InvalidOperationException("EF Core ActivityWriter requires UAuthSession instance."); + + _db.Sessions.Update(updated.ToProjection()); + await _db.SaveChangesAsync(ct); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs new file mode 100644 index 00000000..4a69c9c6 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs @@ -0,0 +1,360 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; +using System.Security; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class EfCoreSessionStore : ISessionStore +{ + private readonly EfCoreSessionStoreKernel _kernel; + private readonly UltimateAuthSessionDbContext _db; + + public EfCoreSessionStore(EfCoreSessionStoreKernel kernel, UltimateAuthSessionDbContext db) + { + _kernel = kernel; + _db = db; + } + + public async Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + { + var projection = await _db.Sessions + .AsNoTracking() + .Where(x => + x.SessionId == sessionId && + x.TenantId == tenantId) + .SingleOrDefaultAsync(ct); + + if (projection is null) + return null; + + return projection.ToDomain(); + } + + public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var now = ctx.IssuedAt; + + var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.TenantId == ctx.TenantId && x.UserId!.Equals(ctx.UserId), ct); + + ISessionRoot root; + + if (rootProjection is null) + { + root = UAuthSessionRoot.Create(ctx.TenantId, ctx.UserId, now); + _db.Roots.Add(root.ToProjection()); + } + else + { + var chains = await LoadChainsAsync(ctx, ct); + root = rootProjection.ToDomain(chains); + } + + + ISessionChain chain; + + if (ctx.ChainId is not null) + { + var chainProjection = await _db.Chains.SingleAsync(x => x.ChainId == ctx.ChainId.Value, ct); + chain = chainProjection.ToDomain(); + } + else + { + chain = UAuthSessionChain.Create( + ChainId.New(), + ctx.TenantId, + ctx.UserId, + root.SecurityVersion, + ClaimsSnapshot.Empty); + + _db.Chains.Add(chain.ToProjection()); + root = root.AttachChain(chain, now); + } + + var session = UAuthSession.Create( + issued.Session.SessionId, + ctx.TenantId, + ctx.UserId, + chain.ChainId, + now, + issued.Session.ExpiresAt, + ctx.DeviceInfo, + issued.Session.Claims, + metadata: SessionMetadata.Empty + ); + + _db.Sessions.Add(session.ToProjection()); + var updatedChain = chain.AttachSession(session.SessionId); + _db.Chains.Update(updatedChain.ToProjection()); + + }, ct); + } + + public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var now = ctx.IssuedAt; + + var oldSessionProjection = await _db.Sessions.SingleOrDefaultAsync( + x => x.SessionId == currentSessionId && + x.TenantId == ctx.TenantId, + ct); + + if (oldSessionProjection is null) + throw new SecurityException("Session not found."); + + var oldSession = oldSessionProjection.ToDomain(); + + var chainProjection = await _db.Chains.SingleOrDefaultAsync( + x => x.ChainId == oldSession.ChainId, ct); + + if (chainProjection is null) + throw new SecurityException("Chain not found."); + + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + throw new SecurityException("Session chain is revoked."); + + var newSession = UAuthSession.Create( + issued.Session.SessionId, + ctx.TenantId, + ctx.UserId, + chain.ChainId, + now, + issued.Session.ExpiresAt, + ctx.DeviceInfo, + issued.Session.Claims, + metadata: SessionMetadata.Empty + ); + + _db.Sessions.Add(newSession.ToProjection()); + + var updatedChain = chain.RotateSession(newSession.SessionId); + _db.Chains.Update(updatedChain.ToProjection()); + + var revokedOldSession = oldSession.Revoke(now); + _db.Sessions.Update(revokedOldSession.ToProjection()); + + }, ct); + } + + public async Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var rootProjection = await _db.Roots + .SingleOrDefaultAsync( + x => x.TenantId == tenantId && + x.UserId!.Equals(userId), + ct); + + if (rootProjection is null) + return; + + var chainProjections = await _db.Chains + .Where(x => + x.TenantId == tenantId && + x.UserId!.Equals(userId)) + .ToListAsync(ct); + + foreach (var chainProjection in chainProjections) + { + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + continue; + + var revokedChain = chain.Revoke(at); + _db.Chains.Update(revokedChain.ToProjection()); + + if (chain.ActiveSessionId is not null) + { + var sessionProjection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == chain.ActiveSessionId && + x.TenantId == tenantId, + ct); + + if (sessionProjection is not null) + { + var session = sessionProjection.ToDomain(); + var revokedSession = session.Revoke(at); + _db.Sessions.Update(revokedSession.ToProjection()); + } + } + } + + var root = rootProjection.ToDomain(chainProjections + .Select(c => c.ToDomain()) + .ToList()); + + var revokedRoot = root.Revoke(at); + _db.Roots.Update(revokedRoot.ToProjection()); + + }, ct); + } + + public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var chainProjection = await _db.Chains + .SingleOrDefaultAsync( + x => x.ChainId == chainId && + x.TenantId == tenantId, + ct); + + if (chainProjection is null) + return; + + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + return; + + var revokedChain = chain.Revoke(at); + _db.Chains.Update(revokedChain.ToProjection()); + + if (chain.ActiveSessionId is not null) + { + var sessionProjection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == chain.ActiveSessionId && + x.TenantId == tenantId, + ct); + + if (sessionProjection is not null) + { + var session = sessionProjection.ToDomain(); + var revokedSession = session.Revoke(at); + _db.Sessions.Update(revokedSession.ToProjection()); + } + } + + }, ct); + } + + public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var sessionProjection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == sessionId && + x.TenantId == tenantId, + ct); + + if (sessionProjection is null) + return; + + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + return; + + var revokedSession = session.Revoke(at); + _db.Sessions.Update(revokedSession.ToProjection()); + + }, ct); + } + + public async Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + { + var projections = await _db.Sessions + .AsNoTracking() + .Where(x => + x.ChainId == chainId && + x.TenantId == tenantId) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + var projections = await _db.Chains + .AsNoTracking() + .Where(x => + x.TenantId == tenantId && + x.UserId!.Equals(userId)) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task?> GetChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + { + var projection = await _db.Chains + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.ChainId == chainId && + x.TenantId == tenantId, + ct); + + return projection?.ToDomain(); + } + + public async Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + { + return await _db.Sessions + .AsNoTracking() + .Where(x => + x.SessionId == sessionId && + x.TenantId == tenantId) + .Select(x => (ChainId?)x.ChainId) + .SingleOrDefaultAsync(ct); + } + + public async Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + { + return await _db.Chains + .AsNoTracking() + .Where(x => + x.ChainId == chainId && + x.TenantId == tenantId) + .Select(x => x.ActiveSessionId) + .SingleOrDefaultAsync(ct); + } + + public async Task?> GetSessionRootAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.TenantId == tenantId && + x.UserId!.Equals(userId), + ct); + + if (rootProjection is null) + return null; + + var chainProjections = await _db.Chains + .AsNoTracking() + .Where(x => + x.TenantId == tenantId && + x.UserId!.Equals(userId)) + .ToListAsync(ct); + + return rootProjection.ToDomain(chainProjections.Select(x => x.ToDomain()).ToList()); + } + + + private async Task>> LoadChainsAsync(SessionStoreContext ctx, CancellationToken ct) + { + var chainProjections = await _db.Chains + .AsNoTracking() + .Where(x => + x.TenantId == ctx.TenantId && + x.UserId!.Equals(ctx.UserId)) + .ToListAsync(ct); + + return chainProjections + .Select(x => x.ToDomain()) + .ToList(); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs new file mode 100644 index 00000000..5982b753 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class EfCoreSessionStoreKernel + { + private readonly UltimateAuthSessionDbContext _db; + + public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db) + { + _db = db; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs new file mode 100644 index 00000000..fac4658a --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class SessionChainProjection + { + public long Id { get; set; } + + public ChainId ChainId { get; set; } = default!; + + public string? TenantId { get; set; } + public TUserId UserId { get; set; } = default!; + + public int RotationCount { get; set; } + public long SecurityVersionAtCreation { get; set; } + + public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; + + public AuthSessionId? ActiveSessionId { get; set; } + + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public byte[] RowVersion { get; set; } = default!; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs new file mode 100644 index 00000000..6698c41d --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class SessionProjection + { + public long Id { get; set; } // EF internal PK + + public AuthSessionId SessionId { get; set; } = default!; + public ChainId ChainId { get; set; } = default!; + + public string? TenantId { get; set; } + public TUserId UserId { get; set; } = default!; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? LastSeenAt { get; set; } + + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public long SecurityVersionAtCreation { get; set; } + + public DeviceInfo Device { get; set; } = DeviceInfo.Empty; + public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; + public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; + + public byte[] RowVersion { get; set; } = default!; + } + +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs new file mode 100644 index 00000000..bc4dc81d --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class SessionRootProjection + { + public long Id { get; set; } + + public string? TenantId { get; set; } + public TUserId UserId { get; set; } = default!; + + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public long SecurityVersion { get; set; } + public DateTimeOffset LastUpdatedAt { get; set; } + + public byte[] RowVersion { get; set; } = default!; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs new file mode 100644 index 00000000..0d5b86db --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class JsonValueConverter : ValueConverter + { + public JsonValueConverter() + : base( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!) + { + } + } +} 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..62ab304a --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal static class SessionChainProjectionMapper + { + public static ISessionChain ToDomain(this SessionChainProjection p) + { + return UAuthSessionChain.FromProjection( + p.ChainId, + p.TenantId, + p.UserId, + p.RotationCount, + p.SecurityVersionAtCreation, + p.ClaimsSnapshot, + p.ActiveSessionId, + p.IsRevoked, + p.RevokedAt + ); + } + + public static SessionChainProjection ToProjection(this ISessionChain chain) + { + return new SessionChainProjection + { + ChainId = chain.ChainId, + TenantId = chain.TenantId, + UserId = chain.UserId, + + RotationCount = chain.RotationCount, + SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + ClaimsSnapshot = chain.ClaimsSnapshot, + + ActiveSessionId = chain.ActiveSessionId, + + IsRevoked = chain.IsRevoked, + RevokedAt = chain.RevokedAt + }; + } + + } +} 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..37cc7554 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -0,0 +1,54 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal static class SessionProjectionMapper + { + public static ISession ToDomain(this SessionProjection p) + { + var device = p.Device == DeviceInfo.Empty + ? DeviceInfo.Unknown + : p.Device; + + return UAuthSession.FromProjection( + p.SessionId, + p.TenantId, + p.UserId, + p.ChainId, + p.CreatedAt, + p.ExpiresAt, + p.LastSeenAt, + p.IsRevoked, + p.RevokedAt, + p.SecurityVersionAtCreation, + device, + p.Claims, + p.Metadata + ); + } + + public static SessionProjection ToProjection(this ISession s) + { + return new SessionProjection + { + SessionId = s.SessionId, + TenantId = s.TenantId, + UserId = s.UserId, + ChainId = s.ChainId, + + CreatedAt = s.CreatedAt, + ExpiresAt = s.ExpiresAt, + LastSeenAt = s.LastSeenAt, + + IsRevoked = s.IsRevoked, + RevokedAt = s.RevokedAt, + + SecurityVersionAtCreation = s.SecurityVersionAtCreation, + Device = s.Device, + Claims = s.Claims, + Metadata = s.Metadata + }; + } + + } +} 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..d7224850 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal static class SessionRootProjectionMapper + { + public static ISessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList> chains) + { + return UAuthSessionRoot.FromProjection( + root.TenantId, + root.UserId, + root.IsRevoked, + root.RevokedAt, + root.SecurityVersion, + chains, + root.LastUpdatedAt + ); + } + + public static SessionRootProjection ToProjection(this ISessionRoot root) + { + return new SessionRootProjection + { + TenantId = root.TenantId, + UserId = root.UserId, + + IsRevoked = root.IsRevoked, + RevokedAt = root.RevokedAt, + + SecurityVersion = root.SecurityVersion, + LastUpdatedAt = root.LastUpdatedAt + }; + } + + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs new file mode 100644 index 00000000..34530586 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class NullableAuthSessionIdConverter : ValueConverter + { + public NullableAuthSessionIdConverter() + : base( + v => v == null ? null : v.Value, + v => v == null ? null : AuthSessionId.From(v)) + { + } + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..54b9dbd1 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull + { + services.AddDbContext>(configureDb); + services.AddScoped>(); + services.AddScoped, EfCoreSessionStore>(); + services.AddScoped, EfCoreSessionActivityWriter>(); + + return services; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs new file mode 100644 index 00000000..fa94bd6a --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs @@ -0,0 +1,104 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class UltimateAuthSessionDbContext : DbContext + { + public DbSet> Roots => Set>(); + public DbSet> Chains => Set>(); + public DbSet> Sessions => Set>(); + + public UltimateAuthSessionDbContext(DbContextOptions options) : base(options) + { + + } + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity>(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.UserId) + .IsRequired(); + + e.HasIndex(x => new { x.TenantId, x.UserId }) + .IsUnique(); + + e.Property(x => x.SecurityVersion) + .IsRequired(); + + e.Property(x => x.LastUpdatedAt) + .IsRequired(); + }); + + b.Entity>(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.UserId) + .IsRequired(); + + e.HasIndex(x => x.ChainId) + .IsUnique(); + + e.Property(x => x.ChainId) + .HasConversion( + v => v.Value, + v => ChainId.From(v)) + .IsRequired(); + + e.Property(x => x.ActiveSessionId) + .HasConversion(new NullableAuthSessionIdConverter()); + + e.Property(x => x.ClaimsSnapshot) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.SecurityVersionAtCreation) + .IsRequired(); + }); + + b.Entity>(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.RowVersion).IsRowVersion(); + + e.HasIndex(x => x.SessionId).IsUnique(); + e.HasIndex(x => new { x.ChainId, x.RevokedAt }); + + e.Property(x => x.SessionId) + .HasConversion( + v => v.Value, + v => AuthSessionId.From(v)) + .IsRequired(); + + e.Property(x => x.ChainId) + .HasConversion( + v => v.Value, + v => ChainId.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(); + }); + } + + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index 24e1e564..b00b646b 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -17,14 +17,10 @@ public InMemorySessionStore(ISessionStoreFactory factory) private ISessionStoreKernel Kernel(string? tenantId) => _factory.Create(tenantId); - public Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId) + public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) => Kernel(tenantId).GetSessionAsync(tenantId, sessionId); - public async Task CreateSessionAsync( - IssuedSession issued, - SessionStoreContext ctx) + public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { var k = Kernel(ctx.TenantId); @@ -82,10 +78,7 @@ await k.SetActiveSessionIdAsync( }); } - public async Task RotateSessionAsync( - AuthSessionId currentSessionId, - IssuedSession issued, - SessionStoreContext ctx) + public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { var k = Kernel(ctx.TenantId); @@ -123,16 +116,10 @@ await k.RevokeSessionAsync( }); } - public Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset at) + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) => Kernel(tenantId).RevokeSessionAsync(tenantId, sessionId, at); - public async Task RevokeAllSessionsAsync( - string? tenantId, - TUserId userId, - DateTimeOffset at) + public async Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) { var k = Kernel(tenantId); @@ -159,10 +146,7 @@ await k.RevokeSessionAsync( }); } - public async Task RevokeChainAsync( - string? tenantId, - ChainId chainId, - DateTimeOffset at) + public async Task RevokeChainAsync(string? tenantId,ChainId chainId, DateTimeOffset at, CancellationToken ct = default) { var k = Kernel(tenantId); 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..9f31a13b --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs new file mode 100644 index 00000000..e20fa105 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs @@ -0,0 +1,174 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class EfCoreTokenStore : ITokenStore +{ + private readonly UltimateAuthTokenDbContext _db; + private readonly EfCoreTokenStoreKernel _kernel; + private readonly ISessionStore _sessions; + private readonly ITokenHasher _hasher; + + public EfCoreTokenStore( + UltimateAuthTokenDbContext db, + EfCoreTokenStoreKernel kernel, + ISessionStore sessions, + ITokenHasher hasher) + { + _db = db; + _kernel = kernel; + _sessions = sessions; + _hasher = hasher; + } + + public Task StoreRefreshTokenAsync(string? tenantId, TUserId userId, AuthSessionId sessionId, string refreshTokenHash, DateTimeOffset expiresAt) + { + return _kernel.ExecuteAsync(ct => + { + _db.RefreshTokens.Add(new RefreshTokenProjection + { + TenantId = tenantId, + TokenHash = refreshTokenHash, + SessionId = sessionId, + ExpiresAt = expiresAt + }); + + return Task.CompletedTask; + }); + } + + public async Task> ValidateRefreshTokenAsync(string? tenantId, string providedRefreshToken, DateTimeOffset now) + { + var hash = _hasher.Hash(providedRefreshToken); + + return await _kernel.ExecuteAsync(async ct => + { + var token = await _db.RefreshTokens + .SingleOrDefaultAsync( + x => x.TokenHash == hash && + x.TenantId == tenantId, + ct); + + if (token is null) + return RefreshTokenValidationResult.Invalid(); + + if (token.RevokedAt != null) + return RefreshTokenValidationResult.ReuseDetected(); + + if (token.ExpiresAt <= now) + { + token.RevokedAt = now; + return RefreshTokenValidationResult.Invalid(); + } + + // Revoke on first use (rotation) + token.RevokedAt = now; + + var session = await _sessions.GetSessionAsync( + tenantId, + token.SessionId, + ct); + + if (session is null || + session.IsRevoked || + session.ExpiresAt <= now) + { + return RefreshTokenValidationResult.Invalid(); + } + + return RefreshTokenValidationResult.Valid( + session.UserId, + session.SessionId); + }); + } + + public Task RevokeRefreshTokenAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) + { + return _kernel.ExecuteAsync(async ct => + { + var tokens = await _db.RefreshTokens + .Where(x => + x.TenantId == tenantId && + x.SessionId == sessionId && + x.RevokedAt == null) + .ToListAsync(ct); + + foreach (var token in tokens) + token.RevokedAt = at; + }); + } + + public Task RevokeAllRefreshTokensAsync(string? tenantId, TUserId _, DateTimeOffset at) + { + return _kernel.ExecuteAsync(async ct => + { + var tokens = await _db.RefreshTokens + .Where(x => + x.TenantId == tenantId && + x.RevokedAt == null) + .ToListAsync(ct); + + foreach (var token in tokens) + token.RevokedAt = at; + }); + } + + // ------------------------------------------------------------ + // JWT ID (JTI) + // ------------------------------------------------------------ + + public Task StoreTokenIdAsync(string? tenantId, string jti, DateTimeOffset expiresAt) + { + return _kernel.ExecuteAsync(ct => + { + _db.RevokedTokenIds.Add(new RevokedTokenIdProjection + { + TenantId = tenantId, + Jti = jti, + ExpiresAt = expiresAt, + RevokedAt = expiresAt + }); + + return Task.CompletedTask; + }); + } + + public async Task IsTokenIdRevokedAsync(string? tenantId, string jti) + { + return await _db.RevokedTokenIds + .AsNoTracking() + .AnyAsync(x => + x.Jti == jti && + x.TenantId == tenantId); + } + + public Task RevokeTokenIdAsync(string? tenantId, string jti, DateTimeOffset at) + { + return _kernel.ExecuteAsync(async ct => + { + var record = await _db.RevokedTokenIds + .SingleOrDefaultAsync( + x => x.Jti == jti && + x.TenantId == tenantId, + ct); + + if (record is null) + { + _db.RevokedTokenIds.Add(new RevokedTokenIdProjection + { + TenantId = tenantId, + Jti = jti, + ExpiresAt = at, + RevokedAt = at + }); + } + else + { + record.RevokedAt = at; + } + }); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs new file mode 100644 index 00000000..0abe987f --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class EfCoreTokenStoreKernel +{ + private readonly UltimateAuthTokenDbContext _db; + + public EfCoreTokenStoreKernel(UltimateAuthTokenDbContext db) + { + _db = db; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted,ct); + + _db.Database.UseTransaction(tx); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + } + + public async Task ExecuteAsync(Func> action,CancellationToken ct = default) + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); + + _db.Database.UseTransaction(tx); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + } +} 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..c3ed93d5 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +// Add mapper class if needed (adding domain rules etc.) +internal sealed class RefreshTokenProjection +{ + public long Id { get; set; } // Surrogate PK + public string? TenantId { get; set; } + + public string TokenHash { get; set; } = default!; + public AuthSessionId SessionId { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public byte[] RowVersion { get; set; } = default!; + + public bool IsRevoked => RevokedAt != null; +} 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..5499bace --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class RevokedTokenIdProjection +{ + public long Id { get; set; } + public string? TenantId { get; set; } + + public string Jti { get; set; } = default!; + + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset RevokedAt { get; set; } + + public byte[] RowVersion { get; set; } = default!; +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..4fff2ab7 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) + { + services.AddDbContext(configureDb); + services.AddScoped(); + services.AddScoped(typeof(ITokenStore<>), typeof(EfCoreTokenStore<>)); + + return services; + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs new file mode 100644 index 00000000..d3cb5e3b --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -0,0 +1,71 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class UltimateAuthTokenDbContext : DbContext +{ + public DbSet RefreshTokens => Set(); + public DbSet RevokedTokenIds => Set(); + + public UltimateAuthTokenDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder b) + { + // ------------------------------------------------- + // REFRESH TOKEN + // ------------------------------------------------- + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.TokenHash) + .IsRequired(); + + e.Property(x => x.SessionId) + .HasConversion( + v => v.Value, + v => new AuthSessionId(v)) + .IsRequired(); + + e.HasIndex(x => x.TokenHash) + .IsUnique(); + + e.HasIndex(x => new { x.TenantId, x.SessionId }); + + e.Property(x => x.ExpiresAt) + .IsRequired(); + }); + + // ------------------------------------------------- + // REVOKED JTI + // ------------------------------------------------- + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.Jti) + .IsRequired(); + + e.HasIndex(x => x.Jti) + .IsUnique(); + + e.HasIndex(x => new { x.TenantId, x.Jti }); + + e.Property(x => x.ExpiresAt) + .IsRequired(); + + e.Property(x => x.RevokedAt) + .IsRequired(); + }); + } +} 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 index 1f3e2def..df84dd42 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj @@ -7,7 +7,7 @@ true $(NoWarn);1591 - + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs index 0e5f9b06..331bdf1d 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs @@ -10,22 +10,14 @@ internal sealed class InMemoryTokenStore : ITokenStore private readonly ISessionStoreFactory _sessions; private readonly ITokenHasher _hasher; - public InMemoryTokenStore( - ITokenStoreFactory factory, - ISessionStoreFactory sessions, - ITokenHasher hasher) + public InMemoryTokenStore(ITokenStoreFactory factory, ISessionStoreFactory sessions, ITokenHasher hasher) { _factory = factory; _sessions = sessions; _hasher = hasher; } - public async Task StoreRefreshTokenAsync( - string? tenantId, - TUserId userId, - AuthSessionId sessionId, - string refreshTokenHash, - DateTimeOffset expiresAt) + public async Task StoreRefreshTokenAsync(string? tenantId, TUserId userId, AuthSessionId sessionId, string refreshTokenHash, DateTimeOffset expiresAt) { var kernel = _factory.Create(tenantId); @@ -58,7 +50,6 @@ public async Task> ValidateRefreshTokenAsy return RefreshTokenValidationResult.Invalid(); } - // one-time use await kernel.RevokeRefreshTokenAsync(tenantId, hash, now); var sessionKernel = _sessions.Create(tenantId); @@ -72,49 +63,32 @@ public async Task> ValidateRefreshTokenAsy session.SessionId); } - public Task RevokeRefreshTokenAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset at) + public Task RevokeRefreshTokenAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) { var kernel = _factory.Create(tenantId); return kernel.RevokeAllRefreshTokensAsync(tenantId, null, at); } - public Task RevokeAllRefreshTokensAsync( - string? tenantId, - TUserId _, - DateTimeOffset at) + public Task RevokeAllRefreshTokensAsync(string? tenantId, TUserId _, DateTimeOffset at) { var kernel = _factory.Create(tenantId); return kernel.RevokeAllRefreshTokensAsync(tenantId, null, at); } - // ------------------------------------------------------------ - // JTI - // ------------------------------------------------------------ - public Task StoreTokenIdAsync( - string? tenantId, - string jti, - DateTimeOffset expiresAt) + public Task StoreTokenIdAsync(string? tenantId, string jti, DateTimeOffset expiresAt) { var kernel = _factory.Create(tenantId); return kernel.StoreTokenIdAsync(tenantId, jti, expiresAt); } - public Task IsTokenIdRevokedAsync( - string? tenantId, - string jti) + public Task IsTokenIdRevokedAsync(string? tenantId, string jti) { var kernel = _factory.Create(tenantId); return kernel.IsTokenIdRevokedAsync(tenantId, jti); } - public Task RevokeTokenIdAsync( - string? tenantId, - string jti, - DateTimeOffset at) + public Task RevokeTokenIdAsync(string? tenantId, string jti, DateTimeOffset at) { var kernel = _factory.Create(tenantId); return kernel.RevokeTokenIdAsync(tenantId, jti, at); diff --git a/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj b/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj deleted file mode 100644 index 88d7cd41..00000000 --- a/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs b/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs deleted file mode 100644 index acaced2a..00000000 --- a/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} 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..2bbcda1d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -0,0 +1,36 @@ + + + + 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..e2d82146 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class AuthSessionIdTests +{ + [Fact] + public void Cannot_create_empty_session_id() + { + Assert.Throws(() => new AuthSessionId(string.Empty)); + } + + [Fact] + public void Equality_is_value_based() + { + var id1 = new AuthSessionId("abc"); + var id2 = new AuthSessionId("abc"); + + Assert.Equal(id1, id2); + Assert.True(id1 == id2); + } +} 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..13011f53 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthSessionChainTests +{ + [Fact] + public void Rotating_chain_increments_rotation_count() + { + var chain = UAuthSessionChain.Create( + ChainId.New(), + tenantId: null, + userId: "user-1", + securityVersion: 0, + ClaimsSnapshot.Empty); + + var rotated = chain.RotateSession(new AuthSessionId("s2")); + + Assert.Equal(1, rotated.RotationCount); + Assert.Equal("s2", rotated.ActiveSessionId?.Value); + } + + [Fact] + public void Revoked_chain_does_not_rotate() + { + var now = DateTimeOffset.UtcNow; + + var chain = UAuthSessionChain.Create( + ChainId.New(), + null, + "user-1", + 0, + ClaimsSnapshot.Empty); + + var revoked = chain.Revoke(now); + var rotated = revoked.RotateSession(new AuthSessionId("s2")); + + Assert.Same(revoked, rotated); + } +} 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..1bf3f358 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -0,0 +1,52 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Xunit; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthSessionTests +{ + [Fact] + public void Revoke_marks_session_as_revoked() + { + var now = DateTimeOffset.UtcNow; + + var session = UAuthSession.Create( + new AuthSessionId("s1"), + tenantId: null, + userId: "user-1", + chainId: ChainId.New(), + now, + now.AddMinutes(10), + DeviceInfo.Unknown, + 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; + + var session = UAuthSession.Create( + new AuthSessionId("s1"), + null, + "user-1", + ChainId.New(), + now, + now.AddMinutes(10), + DeviceInfo.Unknown, + 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/Credentials/CredentialUserMappingBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs new file mode 100644 index 00000000..3f8f07e0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs @@ -0,0 +1,95 @@ +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit +{ + public class CredentialUserMappingBuilderTests + { + private sealed class ConventionUser + { + public Guid Id { get; set; } + public string Email { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public long SecurityVersion { get; set; } + } + + private sealed class ExplicitUser + { + public Guid UserId { get; set; } + public string LoginName { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public long SecurityVersion { get; set; } + } + + private sealed class PlainPasswordUser + { + public Guid Id { get; set; } + public string Username { get; set; } = default!; + public string Password { get; set; } = default!; + public long SecurityVersion { get; set; } + } + + + [Fact] + public void Build_UsesConventions_WhenExplicitMappingIsNotProvided() + { + var options = new CredentialUserMappingOptions(); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ConventionUser + { + Id = Guid.NewGuid(), + Email = "test@example.com", + PasswordHash = "hash", + SecurityVersion = 3 + }; + + Assert.Equal(user.Id, mapping.UserId(user)); + Assert.Equal(user.Email, mapping.Username(user)); + Assert.Equal(user.PasswordHash, mapping.PasswordHash(user)); + Assert.Equal(user.SecurityVersion, mapping.SecurityVersion(user)); + Assert.True(mapping.CanAuthenticate(user)); + } + + [Fact] + public void Build_ExplicitMapping_OverridesConvention() + { + var options = new CredentialUserMappingOptions(); + options.MapUsername(u => u.LoginName); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ExplicitUser + { + UserId = Guid.NewGuid(), + LoginName = "custom-login", + PasswordHash = "hash", + SecurityVersion = 1 + }; + + Assert.Equal("custom-login", mapping.Username(user)); + } + + [Fact] + public void Build_DoesNotMap_PlainPassword_Property() + { + var options = new CredentialUserMappingOptions(); + var ex = Assert.Throws(() => CredentialUserMappingBuilder.Build(options)); + + Assert.Contains("PasswordHash mapping is required", ex.Message); + } + + [Fact] + public void Build_Defaults_CanAuthenticate_ToTrue() + { + var options = new CredentialUserMappingOptions(); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ConventionUser + { + Id = Guid.NewGuid(), + Email = "active@example.com", + PasswordHash = "hash", + SecurityVersion = 0 + }; + + var canAuthenticate = mapping.CanAuthenticate(user); + Assert.True(canAuthenticate); + } + } +} From af592ced0e921d0a8def036f42cda6f8bb718377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:25:52 +0300 Subject: [PATCH 19/50] Revise README with new release details and badges Updated project release timeline and added badges. --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d2a3c9e1..e247afcf 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -⚠️ This project is in development. First preview release expected Q1 2026 - coming soon. -# UltimateAuth -### The Modern Unified Auth Framework for .NET -- Reimagined. -A CodeBeam Project +![UltimateAuth Banner](https://github.com/user-attachments/assets/4204666e-b57a-4cb5-8846-dc7e4f16bfe9) + +⚠️ This project is in development. First preview release expected Q1 2026 - coming soon. ![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) @@ -61,13 +60,12 @@ UltimateAuth is engineered from day one to support real-world scenarios across t > _Dates reflect targeted milestones and may evolve with community feedback._ -### **Q1 2026 — Preview (v 0.1.0)** +### **Q1 2026 — First Release (v 0.0.1 to v 0.1.0)** - Core session-based auth engine ### **Q2 2026 — Stable Feature Release** -- Token-based flows -### **Q3 2026 — v 1.0.0 (General Availability)** +### **Q3 2026 — General Availability** - API surface locked - Production-ready security hardening - Unified architecture finalized From ce143db18498afe759371f007b088f103629fd98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:47:18 +0300 Subject: [PATCH 20/50] Preparation Of First Release (Part 3/7) (#11) * Preparation Of First Release (Part 3/7) * Add AuthenticationStateProvider Support to Blazor Server * Great Enhancements for Blazor Server * Add Tests * Add ValidateAsync into Client Project * Little Changes and Notes --- UltimateAuth.slnx | 2 +- .../Components/Pages/Home.razor | 47 -------- .../Components/Routes.razor | 6 - .../Components/App.razor | 0 .../Components/Layout/MainLayout.razor | 3 +- .../Components/Layout/MainLayout.razor.cs | 12 ++ .../Components/Layout/MainLayout.razor.css | 0 .../Components/Pages/Error.razor | 0 .../Components/Pages/Home.razor | 95 ++++++++++++++++ .../Components/Pages/Home.razor.cs | 68 +++++------ .../Pages/UAuthAuthenticationStateProvider.cs | 22 ++++ .../Components/Routes.razor | 8 ++ .../Components/_Imports.razor | 5 +- .../Program.cs | 30 ++++- .../Properties/launchSettings.json | 0 .../UltimateAuth.Sample.BlazorServer.csproj} | 0 .../appsettings.Development.json | 0 .../appsettings.json | 0 .../wwwroot/app.css | 0 .../wwwroot/bootstrap/bootstrap.min.css | 0 .../wwwroot/bootstrap/bootstrap.min.css.map | 0 .../wwwroot/favicon.png | Bin .../Abstractions/IBrowserPostClient.cs | 2 + .../Abstractions/ISessionCoordinator.cs | 2 + .../AssemblyVisibility.cs | 3 + .../Components/UAuthClientProvider.razor | 4 - .../Components/UAuthClientProvider.razor.cs | 21 +++- .../Contracts/AuthValidationResult.cs | 11 -- .../Contracts/BrowserPostJsonResult.cs | 9 ++ .../Contracts/CoordinatorTerminationReason.cs | 8 ++ .../Diagnostics/UAuthClientDiagnostics.cs | 98 ++++++++++++++++ ...teAuthClientServiceCollectionExtensions.cs | 2 + .../Helpers/.gitkeep | 1 - .../BlazorServerSessionCoordinator.cs | 48 ++++++-- .../Infrastructure/BrowserPostClient.cs | 6 + .../Infrastructure/NoOpSessionCoordinator.cs | 2 + .../Options/UAuthClientOptions.cs | 14 ++- .../Services/IUAuthClient.cs | 2 +- .../Services/UAuthClient.cs | 60 ++++++++-- .../wwwroot/uauth.js | 18 ++- .../Contracts/Session/AuthValidationResult.cs | 14 +++ .../Contracts/Session/SessionRefreshResult.cs | 4 + .../Domain/Principals/ReauthBehavior.cs | 9 ++ .../Domain/Session/ClaimsSnapshot.cs | 33 ++++++ .../Domain/Session/ISession.cs | 3 +- .../Domain/Session/UAuthSession.cs | 13 ++- .../Options/UAuthSessionOptions.cs | 4 + .../UAuthAuthenticationExtension.cs | 12 ++ .../UAuthAuthenticationHandler.cs | 97 ++++++++++++++++ .../Authentication/UAuthCookieDefaults.cs | 6 + .../Authentication/UAuthCookieOptions.cs | 11 ++ .../Contracts/ValidateResponse.cs | 9 -- .../DefaultRefreshEndpointHandler.cs | 5 + .../DefaultValidateEndpointHandler.cs | 30 ++--- .../DefaultUserAuthenticator.cs | 29 ++--- .../Orchestrator/UAuthSessionQueryService.cs | 37 ++---- .../Refresh/DefaultSessionRefreshService.cs | 2 +- .../Services/UAuthFlowService.cs | 1 + .../InMemoryCredentialsSeeder.cs | 5 +- .../BlazorServerSessionCoordinatorTests.cs | 91 +++++++++++++++ .../Client/ClientDiagnosticsTests.cs | 106 ++++++++++++++++++ .../Client/RefreshOutcomeParserTests.cs | 34 ++++++ .../Fake/FakeNavigationManager.cs | 19 ++++ .../Fake/FakeUAuthClient.cs | 51 +++++++++ 64 files changed, 1006 insertions(+), 228 deletions(-) delete mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor delete mode 100644 samples/blazor-server/UltimateAuth.BlazorServer/Components/Routes.razor rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/Components/App.razor (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/Components/Layout/MainLayout.razor (76%) create mode 100644 samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/Components/Layout/MainLayout.razor.css (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/Components/Pages/Error.razor (100%) create mode 100644 samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/Components/Pages/Home.razor.cs (57%) create mode 100644 samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/UAuthAuthenticationStateProvider.cs create mode 100644 samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Routes.razor rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/Components/_Imports.razor (77%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/Program.cs (68%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/Properties/launchSettings.json (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj => UltimateAuth.Sample.BlazorServer/UltimateAuth.Sample.BlazorServer.csproj} (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/appsettings.Development.json (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/appsettings.json (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/wwwroot/app.css (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/wwwroot/bootstrap/bootstrap.min.css (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/wwwroot/bootstrap/bootstrap.min.css.map (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer => UltimateAuth.Sample.BlazorServer}/wwwroot/favicon.png (100%) create mode 100644 src/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/AuthValidationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Helpers/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieDefaults.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieOptions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/ValidateResponse.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeNavigationManager.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 9f87acca..99d138b0 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,6 +1,6 @@ - + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor deleted file mode 100644 index f4a57bd2..00000000 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor +++ /dev/null @@ -1,47 +0,0 @@ -@page "/" -@page "/login" -@using CodeBeam.UltimateAuth.Client -@using CodeBeam.UltimateAuth.Core.Abstractions -@using CodeBeam.UltimateAuth.Core.Runtime -@using CodeBeam.UltimateAuth.Server.Abstractions -@using CodeBeam.UltimateAuth.Server.Cookies -@using CodeBeam.UltimateAuth.Server.Infrastructure -@inject IUAuthFlowService Flow -@inject ISnackbar Snackbar -@inject ISessionQueryService SessionQuery -@inject ICredentialResolver CredentialResolver -@inject IClock Clock -@inject IUAuthCookieManager CookieManager -@inject IHttpContextAccessor HttpContextAccessor -@inject IUAuthClient UAuthClient -@inject NavigationManager Nav -@inject IUAuthProductInfoProvider ProductInfo - - -
- - - - Welcome to UltimateAuth! - - - Login - - - - - Validate - Logout - Refresh - - - - Programmatic Login - - - - @ProductInfo.Get().ProductName v @ProductInfo.Get().Version - Client Profile: @ProductInfo.Get().ClientProfile.ToString() - - -
diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Routes.razor b/samples/blazor-server/UltimateAuth.BlazorServer/Components/Routes.razor deleted file mode 100644 index f756e19d..00000000 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Routes.razor +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/App.razor similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/Components/App.razor rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/App.razor diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor similarity index 76% rename from samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor index 69ed8f6d..a31ffa36 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -1,6 +1,7 @@ @inherits LayoutComponentBase +@inject ISnackbar Snackbar - + diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs new file mode 100644 index 00000000..df17c947 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs @@ -0,0 +1,12 @@ +using MudBlazor; + +namespace UltimateAuth.Sample.BlazorServer.Components.Layout +{ + public partial class MainLayout + { + private void HandleReauth() + { + Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); + } + } +} diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor.css b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/Components/Layout/MainLayout.razor.css rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Error.razor b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Error.razor rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor new file mode 100644 index 00000000..668af392 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -0,0 +1,95 @@ +@page "/" +@page "/login" +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Runtime +@using CodeBeam.UltimateAuth.Server.Abstractions +@using CodeBeam.UltimateAuth.Server.Cookies +@using CodeBeam.UltimateAuth.Server.Infrastructure +@inject IUAuthFlowService Flow +@inject ISnackbar Snackbar +@inject ISessionQueryService SessionQuery +@inject ICredentialResolver CredentialResolver +@inject IClock Clock +@inject IUAuthCookieManager CookieManager +@inject IHttpContextAccessor HttpContextAccessor +@inject IUAuthClient UAuthClient +@inject NavigationManager Nav +@inject IUAuthProductInfoProvider ProductInfo +@inject AuthenticationStateProvider AuthStateProvider +@inject UAuthClientDiagnostics Diagnostics + + +
+ + + + Welcome to UltimateAuth! + + + Login + + + + + Validate + Logout + Refresh + + + + Programmatic Login + + + + @ProductInfo.Get().ProductName v @ProductInfo.Get().Version + Client Profile: @ProductInfo.Get().ClientProfile.ToString() + + + + State of Authentication: + @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name) + + + Authorized context is shown. + + + Not Authorized context is shown. + + + + + + + UltimateAuth Client Diagnostics + + + + Started: @Diagnostics.StartCount - @Diagnostics.StartedAt + Stopped: @Diagnostics.StopCount - @Diagnostics.StoppedAt + Terminated: @Diagnostics.TerminatedCount - @Diagnostics.TerminatedAt (@Diagnostics.TerminationReason.ToString()) + + + + Refresh Attempts: @Diagnostics.RefreshAttemptCount + Auto: @Diagnostics.AutomaticRefreshCount + Manual: @Diagnostics.ManualRefreshCount + + + Touched Success: @Diagnostics.RefreshTouchedCount + + + No-Op Success: @Diagnostics.RefreshNoOpCount + + + ReauthRequired: @Diagnostics.RefreshReauthRequiredCount + + + Unknown: @Diagnostics.RefreshUnknownCount + + + + + +
diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs similarity index 57% rename from samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 6d506925..42c4331e 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -1,9 +1,9 @@ using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; -namespace UltimateAuth.BlazorServer.Components.Pages +namespace UltimateAuth.Sample.BlazorServer.Components.Pages { public partial class Home { @@ -12,6 +12,19 @@ public partial class Home private UALoginForm _form = null!; + private AuthenticationState _authState = null!; + + protected override async Task OnInitializedAsync() + { + Diagnostics.Changed += OnDiagnosticsChanged; + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + } + + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } + private async Task ProgrammaticLogin() { var request = new LoginRequest @@ -20,51 +33,16 @@ private async Task ProgrammaticLogin() Secret = "Password!", }; await UAuthClient.LoginAsync(request); + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); } private async Task ValidateAsync() { - var httpContext = HttpContextAccessor.HttpContext; - - if (httpContext is null) - { - Snackbar.Add("HttpContext not available", Severity.Error); - return; - } - - var credential = CredentialResolver.Resolve(httpContext); - - if (credential is null) - { - Snackbar.Add("No credential found", Severity.Error); - return; - } - - if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) - { - Snackbar.Add("Invalid session id", Severity.Error); - return; - } + var result = await UAuthClient.ValidateAsync(); - var result = await SessionQuery.ValidateSessionAsync( - new SessionValidationContext - { - TenantId = credential.TenantId, - SessionId = sessionId, - Device = credential.Device, - Now = Clock.UtcNow - }); - - if (result.IsValid) - { - Snackbar.Add("Session is valid ✅", Severity.Success); - } - else - { - Snackbar.Add( - $"Session invalid ❌ ({result.State})", - Severity.Error); - } + Snackbar.Add( + result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", + result.IsValid ? Severity.Success : Severity.Error); } private async Task LogoutAsync() @@ -76,7 +54,6 @@ private async Task LogoutAsync() private async Task RefreshAsync() { await UAuthClient.RefreshAsync(); - //Snackbar.Add("Logged out", Severity.Success); } protected override void OnAfterRender(bool firstRender) @@ -114,5 +91,10 @@ private void ClearQueryString() Nav.NavigateTo(clean, replace: true); } + public void Dispose() + { + Diagnostics.Changed -= OnDiagnosticsChanged; + } + } } diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/UAuthAuthenticationStateProvider.cs b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/UAuthAuthenticationStateProvider.cs new file mode 100644 index 00000000..5319d4de --- /dev/null +++ b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/UAuthAuthenticationStateProvider.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Components.Authorization; + +namespace CodeBeam.UltimateAuth.Client.BlazorServer; + +internal sealed class UAuthAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly AuthenticationStateProvider _inner; + + public UAuthAuthenticationStateProvider(AuthenticationStateProvider inner) + { + _inner = inner; + + _inner.AuthenticationStateChanged += s => NotifyAuthenticationStateChanged(s); + } + + public override Task GetAuthenticationStateAsync() => _inner.GetAuthenticationStateAsync(); + + /// + /// Call this after login/logout navigation + /// + public void NotifyStateChanged() => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); +} diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Routes.razor b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Routes.razor new file mode 100644 index 00000000..792148c7 --- /dev/null +++ b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Routes.razor @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/_Imports.razor similarity index 77% rename from samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/_Imports.razor index 7e568a67..f0e42821 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Components/_Imports.razor +++ b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/_Imports.razor @@ -2,12 +2,13 @@ @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing +@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.BlazorServer -@using UltimateAuth.BlazorServer.Components +@using UltimateAuth.Sample.BlazorServer +@using UltimateAuth.Sample.BlazorServer.Components @using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Domain diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Program.cs similarity index 68% rename from samples/blazor-server/UltimateAuth.BlazorServer/Program.cs rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/Program.cs index 12fb91c5..81598dff 100644 --- a/samples/blazor-server/UltimateAuth.BlazorServer/Program.cs +++ b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,15 +1,20 @@ +using CodeBeam.UltimateAuth.Client.BlazorServer; using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.InMemory; using CodeBeam.UltimateAuth.Security.Argon2; +using CodeBeam.UltimateAuth.Server.Authentication; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; using MudBlazor.Services; using MudExtensions.Services; -using UltimateAuth.BlazorServer.Components; +using UltimateAuth.Sample.BlazorServer.Components; var builder = WebApplication.CreateBuilder(args); @@ -24,7 +29,15 @@ builder.Services.AddMudServices(); builder.Services.AddMudExtensions(); -builder.Services.AddAuthentication(); +builder.Services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = UAuthCookieDefaults.AuthenticationScheme; + options.DefaultSignInScheme = UAuthCookieDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = UAuthCookieDefaults.AuthenticationScheme; + }) + .AddUAuthCookies(); + builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor(); @@ -32,14 +45,23 @@ builder.Services.AddUltimateAuth(); builder.Services.AddUltimateAuthServer(o => { - o.Diagnostics.EnableRefreshHeaders = true; + o.Diagnostics.EnableRefreshHeaders = true; + //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); + //o.Session.TouchInterval = TimeSpan.FromSeconds(9); + //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); }) .AddInMemoryCredentials() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() .AddUltimateAuthArgon2(); -builder.Services.AddUltimateAuthClient(); +builder.Services.AddUltimateAuthClient(o => +{ + //o.Refresh.Interval = TimeSpan.FromSeconds(5); + o.Reauth.Behavior = ReauthBehavior.RaiseEvent; +}); + + builder.Services.AddScoped(sp => { diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/Properties/launchSettings.json b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/Properties/launchSettings.json rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/UltimateAuth.Sample.BlazorServer.csproj similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/UltimateAuth.BlazorServer.csproj rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/UltimateAuth.Sample.BlazorServer.csproj diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/appsettings.Development.json b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/appsettings.Development.json similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/appsettings.Development.json rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/appsettings.Development.json diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/appsettings.json b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/appsettings.json similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/appsettings.json rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/appsettings.json diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/app.css b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/app.css similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/app.css rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/app.css diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map diff --git a/samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/favicon.png b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/favicon.png similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer/wwwroot/favicon.png rename to samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/favicon.png diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs index b591bb7d..1a0c786d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs @@ -20,5 +20,7 @@ public interface IBrowserPostClient /// /// Task BackgroundPostAsync(string endpoint); + + Task> BackgroundPostJsonAsync(string url); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs index 4919a994..d71d9f57 100644 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs @@ -12,5 +12,7 @@ public interface ISessionCoordinator : IAsyncDisposable /// Stops coordination (optional). ///
Task StopAsync(); + + event Action? ReauthRequired; } } diff --git a/src/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor index e743792b..6f5d1de5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor @@ -3,7 +3,3 @@ @inject ISessionCoordinator Coordinator @implements IAsyncDisposable - -@code { - -} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs index b30f5224..a958d7bc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs @@ -1,10 +1,21 @@ -namespace CodeBeam.UltimateAuth.Client +using CodeBeam.UltimateAuth.Client.Diagnostics; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client { // TODO: Add CircuitHandler to manage start/stop of coordinator in server-side Blazor - public partial class UAuthClientProvider + public partial class UAuthClientProvider : ComponentBase, IAsyncDisposable { private bool _started; + [Parameter] + public EventCallback OnReauthRequired { get; set; } + + protected override async Task OnInitializedAsync() + { + Coordinator.ReauthRequired += HandleReauthRequired; + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender || _started) @@ -14,6 +25,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await Coordinator.StartAsync(); } + private async void HandleReauthRequired() + { + if (OnReauthRequired.HasDelegate) + await OnReauthRequired.InvokeAsync(); + } + public async ValueTask DisposeAsync() { await Coordinator.StopAsync(); diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/AuthValidationResult.cs deleted file mode 100644 index 23c102c8..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/AuthValidationResult.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CodeBeam.UltimateAuth.Client.Contracts -{ - public sealed record AuthValidationResult - { - public bool IsValid { get; init; } - public string? State { get; init; } - - public int? RemainingAttempts { get; init; } - } - -} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs new file mode 100644 index 00000000..e6449d05 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Client.Contracts +{ + public sealed record BrowserPostJsonResult + { + public bool Ok { get; init; } + public int Status { get; init; } + public T? Body { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs new file mode 100644 index 00000000..58a398b2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client.Contracts +{ + public enum CoordinatorTerminationReason + { + None = 0, + ReauthRequired = 1 + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs new file mode 100644 index 00000000..b136ef9e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs @@ -0,0 +1,98 @@ +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 RefreshNoOpCount { get; private set; } + public int RefreshReauthRequiredCount { get; private set; } + public int RefreshUnknownCount { get; private set; } + + 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 MarkRefreshNoOp() + { + RefreshNoOpCount++; + Changed?.Invoke(); + } + + internal void MarkRefreshReauthRequired() + { + RefreshReauthRequiredCount++; + Changed?.Invoke(); + } + + internal void MarkRefreshUnknown() + { + RefreshUnknownCount++; + Changed?.Invoke(); + } + + internal void MarkTerminated(CoordinatorTerminationReason reason) + { + IsTerminated = true; + TerminatedAt = DateTimeOffset.UtcNow; + TerminationReason = reason; + Interlocked.Increment(ref _terminatedCount); + Changed?.Invoke(); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs index 9e88d23d..67c0e413 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Options; @@ -99,6 +100,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } 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/Infrastructure/BlazorServerSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs index e23b9154..f7228640 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs @@ -1,4 +1,6 @@ using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Components; @@ -11,15 +13,19 @@ internal sealed class BlazorServerSessionCoordinator : ISessionCoordinator private readonly IUAuthClient _client; private readonly NavigationManager _navigation; private readonly UAuthClientOptions _options; + private readonly UAuthClientDiagnostics _diagnostics; private PeriodicTimer? _timer; private CancellationTokenSource? _cts; - public BlazorServerSessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options) + public event Action? ReauthRequired; + + public BlazorServerSessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics) { _client = client; _navigation = navigation; _options = options.Value; + _diagnostics = diagnostics; } public async Task StartAsync(CancellationToken cancellationToken = default) @@ -27,11 +33,9 @@ public async Task StartAsync(CancellationToken cancellationToken = default) if (_timer is not null) return; + _diagnostics.MarkStarted(); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - var interval = _options.Refresh.Interval - ?? TimeSpan.FromMinutes(5); - + var interval = _options.Refresh.Interval ?? TimeSpan.FromMinutes(5); _timer = new PeriodicTimer(interval); _ = RunAsync(_cts.Token); @@ -43,15 +47,36 @@ private async Task RunAsync(CancellationToken ct) { while (await _timer!.WaitForNextTickAsync(ct)) { - var result = await _client.RefreshAsync(); + _diagnostics.MarkAutomaticRefresh(); + var result = await _client.RefreshAsync(isAuto: true); - if (result.Outcome == RefreshOutcome.ReauthRequired) + switch (result.Outcome) { - _navigation.NavigateTo( - _options.Endpoints.Login, - forceLoad: true); + case RefreshOutcome.Touched: + break; + + case RefreshOutcome.NoOp: + break; + + case RefreshOutcome.None: + break; + + case RefreshOutcome.ReauthRequired: + switch (_options.Reauth.Behavior) + { + case ReauthBehavior.RedirectToLogin: + _navigation.NavigateTo(_options.Reauth.LoginPath, forceLoad: true); + break; + + case ReauthBehavior.RaiseEvent: + ReauthRequired?.Invoke(); + break; - return; + case ReauthBehavior.None: + break; + } + _diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + return; } } } @@ -63,6 +88,7 @@ private async Task RunAsync(CancellationToken ct) public Task StopAsync() { + _diagnostics.MarkStopped(); _cts?.Cancel(); _timer?.Dispose(); _timer = null; diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs index 147ed605..ad0cb789 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs @@ -24,5 +24,11 @@ public async Task BackgroundPostAsync(string endpoint) return result; } + public async Task> BackgroundPostJsonAsync(string url) + { + var result = await _js.InvokeAsync>("uauth.validate", url); + return result; + } + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs index 6e37f1b5..5784d106 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs @@ -4,6 +4,8 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure { internal sealed class NoOpSessionCoordinator : ISessionCoordinator { + public event Action? ReauthRequired; + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index f3216d16..10aa8d00 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -1,9 +1,13 @@ -namespace CodeBeam.UltimateAuth.Client.Options +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Client.Options { public sealed class UAuthClientOptions { public AuthEndpointOptions Endpoints { get; set; } = new(); public UAuthClientRefreshOptions Refresh { get; set; } = new(); + public ReauthOptions Reauth { get; init; } = new(); } public sealed class AuthEndpointOptions @@ -12,6 +16,7 @@ public sealed class AuthEndpointOptions public string Logout { get; set; } = "/auth/logout"; public string Refresh { get; set; } = "/auth/refresh"; public string Reauth { get; set; } = "/auth/reauth"; + public string Validate { get; set; } = "/auth/validate"; } public sealed class UAuthClientRefreshOptions @@ -33,4 +38,11 @@ public sealed class UAuthClientRefreshOptions ///
public TimeSpan? Jitter { get; set; } } + + // TODO: Add ClearCookieOnReauth + public sealed class ReauthOptions + { + public ReauthBehavior Behavior { get; set; } = ReauthBehavior.RedirectToLogin; + public string LoginPath { get; set; } = "/login"; + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs index fc9f1805..7709c2df 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -7,7 +7,7 @@ public interface IUAuthClient { Task LoginAsync(LoginRequest request); Task LogoutAsync(); - Task RefreshAsync(); + Task RefreshAsync(bool isAuto = false); Task ReauthAsync(); Task ValidateAsync(); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index adea326d..c0f75e82 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -1,9 +1,11 @@ using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Client @@ -12,43 +14,77 @@ internal sealed class UAuthClient : IUAuthClient { private readonly IBrowserPostClient _post; private readonly UAuthClientOptions _options; + private readonly UAuthClientDiagnostics _diagnostics; public UAuthClient( IBrowserPostClient post, - IOptions options) + IOptions options, + UAuthClientDiagnostics diagnostics) { _post = post; _options = options.Value; + _diagnostics = diagnostics; } public async Task LoginAsync(LoginRequest request) - => await _post.NavigatePostAsync(_options.Endpoints.Login, request.ToDictionary()); + { + await _post.NavigatePostAsync(_options.Endpoints.Login, request.ToDictionary()); + } public async Task LogoutAsync() - => await _post.NavigatePostAsync(_options.Endpoints.Logout); + { + await _post.NavigatePostAsync(_options.Endpoints.Logout); + } - public async Task RefreshAsync() + public async Task RefreshAsync(bool isAuto = false) { - var result = await _post.BackgroundPostAsync( - _options.Endpoints.Refresh); + if (isAuto == false) + { + _diagnostics.MarkManualRefresh(); + } + + var result = await _post.BackgroundPostAsync(_options.Endpoints.Refresh); + var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); + switch (refreshOutcome) + { + case RefreshOutcome.NoOp: + _diagnostics.MarkRefreshNoOp(); + break; + case RefreshOutcome.Touched: + _diagnostics.MarkRefreshTouched(); + break; + case RefreshOutcome.ReauthRequired: + _diagnostics.MarkRefreshReauthRequired(); + break; + case RefreshOutcome.None: + _diagnostics.MarkRefreshUnknown(); + break; + } return new RefreshResult { Ok = result.Ok, Status = result.Status, - Outcome = RefreshOutcomeParser.Parse(result.RefreshOutcome) + Outcome = refreshOutcome }; } public Task ReauthAsync() => _post.NavigatePostAsync(_options.Endpoints.Reauth); - public Task ValidateAsync() + public async Task ValidateAsync() { - // Blazor Server: direct service - // WASM: HttpClient - throw new NotImplementedException(); + var result = await _post.BackgroundPostJsonAsync(_options.Endpoints.Validate); + + if (result.Body is null) + return new AuthValidationResult { IsValid = false, State = "transport" }; + + return new AuthValidationResult + { + IsValid = result.Body.IsValid, + State = result.Body.State + }; } - } + } } diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js index b8e9ae75..daf87d1a 100644 --- a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js +++ b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js @@ -35,7 +35,23 @@ window.uauth = { return { ok: response.ok, status: response.status, - refresh: response.headers.get("X-UAuth-Refresh") + refreshOutcome: response.headers.get("X-UAuth-Refresh") + }; + }, + + validate: async function (action) { + const response = await fetch(action, { + method: "POST", + credentials: "include" + }); + + let body = null; + try { body = await response.json(); } catch { } + + return { + ok: response.ok, + status: response.status, + body: body }; } }; 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..b0af06c4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record AuthValidationResult + { + public bool IsValid { get; init; } + public string? State { get; init; } + + public int? RemainingAttempts { get; init; } + + public static AuthValidationResult Valid() => new() { IsValid = true, State = "active" }; + + public static AuthValidationResult Invalid(string state) => new() { IsValid = false, State = state }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs index fc7c9648..8edd1680 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -9,6 +9,7 @@ public sealed record SessionRefreshResult public PrimaryToken? PrimaryToken { get; init; } public RefreshToken? RefreshToken { get; init; } + public bool DidTouch { get; init; } public bool IsSuccess => Status == SessionRefreshStatus.Success; @@ -44,5 +45,8 @@ public static SessionRefreshResult Failed() { Status = SessionRefreshStatus.Failed }; + + public bool RequiresReauth => Status == SessionRefreshStatus.ReauthRequired; + } } 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..3bf0cd41 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public enum ReauthBehavior + { + RedirectToLogin, + None, + RaiseEvent + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index 9cf5ad5a..fc73a9a8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -63,6 +63,39 @@ public static ClaimsSnapshot From(params (string Type, string Value)[] claims) return new ClaimsSnapshot(dict); } + public ClaimsSnapshot With(params (string Type, string Value)[] claims) + { + if (claims.Length == 0) + return this; + + var dict = new Dictionary(_claims, StringComparer.Ordinal); + + foreach (var (type, value) in claims) + { + dict[type] = value; + } + + return new ClaimsSnapshot(dict); + } + + public ClaimsSnapshot Merge(ClaimsSnapshot other) + { + if (other is null || other._claims.Count == 0) + return this; + + if (_claims.Count == 0) + return other; + + var dict = new Dictionary(_claims, StringComparer.Ordinal); + + foreach (var kv in other._claims) + { + dict[kv.Key] = kv.Value; + } + + return new ClaimsSnapshot(dict); + } + // TODO: Add ToClaimsPrincipal and FromClaimsPrincipal methods } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs index 9de7e7e3..42da3353 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -72,9 +72,8 @@ public interface ISession /// Computes the effective runtime state of the session (Active, Expired, /// Revoked, SecurityVersionMismatch, etc.) based on the provided timestamp. /// - /// Current timestamp used for comparisons. /// The evaluated of this session. - SessionState GetState(DateTimeOffset now); + SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout); ISession Touch(DateTimeOffset now); ISession Revoke(DateTimeOffset at); diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 154aeafd..d7f5c5a4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -168,10 +168,17 @@ internal static UAuthSession FromProjection( ); } - public SessionState GetState(DateTimeOffset at) + public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) { - if (IsRevoked) return SessionState.Revoked; - if (at >= ExpiresAt) return SessionState.Expired; + if (IsRevoked) + return SessionState.Revoked; + + if (at >= ExpiresAt) + return SessionState.Expired; + + if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) + return SessionState.Expired; + return SessionState.Active; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 90816cc7..027b7f62 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -2,6 +2,10 @@ namespace CodeBeam.UltimateAuth.Core.Options { + // TODO: Add rotate on refresh (especially on pureopaque). Currently PureOpaque sessions do not rotate on refresh. + // It's not a security branch, but it would be nice to have for privacy reasons. + // And add RotateAsync method. + /// /// Defines configuration settings that control the lifecycle, /// security behavior, and device constraints of UltimateAuth diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs new file mode 100644 index 00000000..b759701c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authentication; + +namespace CodeBeam.UltimateAuth.Server.Authentication; + +public static class UAuthAuthenticationExtensions +{ + public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder builder) + { + return builder.AddScheme(UAuthCookieDefaults.AuthenticationScheme, + _ => { }); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs new file mode 100644 index 00000000..b6f255bc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -0,0 +1,97 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.Authentication; + +internal sealed class UAuthAuthenticationHandler + : AuthenticationHandler +{ + private readonly ICredentialResolver _credentialResolver; + private readonly ISessionQueryService _sessionQuery; + private readonly IClock _clock; + + public UAuthAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder, + ISystemClock clock, + ICredentialResolver credentialResolver, + ISessionQueryService sessionQuery, + IClock uauthClock) + : base(options, logger, encoder, clock) + { + _credentialResolver = credentialResolver; + _sessionQuery = sessionQuery; + _clock = uauthClock; + } + protected override async Task HandleAuthenticateAsync() + { + // 1️⃣ Credential al + var credential = _credentialResolver.Resolve(Context); + + if (credential is null) + return AuthenticateResult.NoResult(); + + // 2️⃣ SessionId parse et + if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) + return AuthenticateResult.Fail("Invalid session id"); + + // 3️⃣ Session validate et + var result = await _sessionQuery.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = credential.TenantId, + SessionId = sessionId, + Device = credential.Device, + Now = _clock.UtcNow + }); + + if (!result.IsValid) + return AuthenticateResult.NoResult(); + + // 4️⃣ Claims üret + var principal = CreatePrincipal(result); + + // 5️⃣ Ticket üret + var ticket = new AuthenticationTicket( + principal, + UAuthCookieDefaults.AuthenticationScheme); + + return AuthenticateResult.Success(ticket); + } + + private static ClaimsPrincipal CreatePrincipal(SessionValidationResult result) + { + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, result.Session.UserId.Value), + new Claim("uauth:session_id", result.Session.SessionId.Value) + }; + + if (!string.IsNullOrEmpty(result.TenantId)) + { + claims.Add(new Claim("uauth:tenant", result.TenantId)); + } + + // Session claims (snapshot) + foreach (var (key, value) in result.Session.Claims.AsDictionary()) + { + claims.Add(new Claim(key, value)); + } + + var identity = new ClaimsIdentity( + claims, + UAuthCookieDefaults.AuthenticationScheme); + + return new ClaimsPrincipal(identity); + } + +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieDefaults.cs new file mode 100644 index 00000000..0fe1e11c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieDefaults.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Server.Authentication; + +public static class UAuthCookieDefaults +{ + public const string AuthenticationScheme = "UltimateAuth"; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieOptions.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieOptions.cs new file mode 100644 index 00000000..fee6cc96 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieOptions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authentication; + +namespace CodeBeam.UltimateAuth.Server.Authentication; + +public sealed class UAuthCookieOptions : AuthenticationSchemeOptions +{ + // TODO: + // - CookieName override + // - Claim mapping + // - Diagnostics flag +} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/ValidateResponse.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/ValidateResponse.cs deleted file mode 100644 index adc95c75..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/ValidateResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Contracts -{ - public sealed record ValidateResponse - { - public bool Valid { get; init; } - - public string? State { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs index 1f2a8411..b52db75a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs @@ -60,7 +60,12 @@ public async Task RefreshAsync(HttpContext ctx) if (!validation.IsValid) + { + if (_options.Diagnostics.EnableRefreshHeaders) + _refreshResponseWriter.Write(ctx, RefreshOutcome.ReauthRequired); return Results.Unauthorized(); + } + var refreshResult = await _sessionRefresh.RefreshAsync(validation, now, ctx.RequestAborted); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs index 9f55a9c6..0b991751 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs @@ -33,9 +33,9 @@ public async Task ValidateAsync( if (credential is null) { return Results.Json( - new ValidateResponse + new AuthValidationResult { - Valid = false, + IsValid = false, State = "missing" }, statusCode: StatusCodes.Status401Unauthorized @@ -47,9 +47,9 @@ public async Task ValidateAsync( if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) { return Results.Json( - new ValidateResponse + new AuthValidationResult { - Valid = false, + IsValid = false, State = "invalid" }, statusCode: StatusCodes.Status401Unauthorized @@ -66,28 +66,18 @@ public async Task ValidateAsync( }, ct); - if (!result.IsValid) + return Results.Ok(new AuthValidationResult { - return Results.Json( - new ValidateResponse - { - Valid = false, - State = result.State - .ToString() - .ToLowerInvariant() - }, - statusCode: StatusCodes.Status401Unauthorized - ); - } - - return Results.Ok(new ValidateResponse { Valid = true }); + IsValid = result.IsValid, + State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant() + }); } // Stateless (JWT / Opaque) – 0.0.1 no support yet return Results.Json( - new ValidateResponse + new AuthValidationResult { - Valid = false, + IsValid = false, State = "unsupported" }, statusCode: StatusCodes.Status401Unauthorized diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs index 0dfe9a47..a2e7b7ab 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; namespace CodeBeam.UltimateAuth.Server.Infrastructure { @@ -8,18 +10,13 @@ public sealed class DefaultUserAuthenticator : IUserAuthenticator _userStore; private readonly IUAuthPasswordHasher _passwordHasher; - public DefaultUserAuthenticator( - IUAuthUserStore userStore, - IUAuthPasswordHasher passwordHasher) + public DefaultUserAuthenticator(IUAuthUserStore userStore, IUAuthPasswordHasher passwordHasher) { _userStore = userStore; _passwordHasher = passwordHasher; } - public async Task> AuthenticateAsync( - string? tenantId, - AuthenticationContext context, - CancellationToken ct = default) + public async Task> AuthenticateAsync(string? tenantId, AuthenticationContext context, CancellationToken ct = default) { if (context is null) throw new ArgumentNullException(nameof(context)); @@ -27,10 +24,7 @@ public async Task> AuthenticateAsync( if (!string.Equals(context.CredentialType, "password", StringComparison.Ordinal)) return UserAuthenticationResult.Fail(); - var user = await _userStore.FindByUsernameAsync( - tenantId, - context.Identifier, - ct); + var user = await _userStore.FindByUsernameAsync(tenantId, context.Identifier, ct); if (user is null || !user.IsActive) return UserAuthenticationResult.Fail(); @@ -38,10 +32,19 @@ public async Task> AuthenticateAsync( if (!_passwordHasher.Verify(context.Secret, user.PasswordHash)) return UserAuthenticationResult.Fail(); + var claims = (user.Claims ?? ClaimsSnapshot.Empty) + .With( + (ClaimTypes.NameIdentifier, user.Id.ToString()!), + (ClaimTypes.Name, user.Username), + ("uauth:username", user.Username) + ); + return UserAuthenticationResult.Success( user.Id, - user.Claims, - user.RequiresMfa); + claims, + user.RequiresMfa + ); + } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs index 40aeadc5..638449a7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs @@ -21,28 +21,19 @@ public async Task> ValidateSessionAsync(Session { var kernel = _storeFactory.Create(context.TenantId); - var session = await kernel.GetSessionAsync( - context.TenantId, - context.SessionId); - + var session = await kernel.GetSessionAsync(context.TenantId,context.SessionId); if (session is null) return SessionValidationResult.Invalid(SessionState.NotFound); - var state = session.GetState(context.Now); + var state = session.GetState(context.Now, _options.Session.IdleTimeout); if (state != SessionState.Active) return SessionValidationResult.Invalid(state); - var chain = await kernel.GetChainAsync( - context.TenantId, - session.ChainId); - + var chain = await kernel.GetChainAsync(context.TenantId, session.ChainId); if (chain is null || chain.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked); - var root = await kernel.GetSessionRootAsync( - context.TenantId, - session.UserId); - + var root = await kernel.GetSessionRootAsync(context.TenantId, session.UserId); if (root is null || root.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked); @@ -56,37 +47,25 @@ public async Task> ValidateSessionAsync(Session return SessionValidationResult.Active(context.TenantId, session, chain, root); } - public Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId, - CancellationToken ct = default) + public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { var kernel = _storeFactory.Create(tenantId); return kernel.GetSessionAsync(tenantId, sessionId); } - public Task>> GetSessionsByChainAsync( - string? tenantId, - ChainId chainId, - CancellationToken ct = default) + public Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) { var kernel = _storeFactory.Create(tenantId); return kernel.GetSessionsByChainAsync(tenantId, chainId); } - public Task>> GetChainsByUserAsync( - string? tenantId, - TUserId userId, - CancellationToken ct = default) + public Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) { var kernel = _storeFactory.Create(tenantId); return kernel.GetChainsByUserAsync(tenantId, userId); } - public Task ResolveChainIdAsync( - string? tenantId, - AuthSessionId sessionId, - CancellationToken ct = default) + public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { var kernel = _storeFactory.Create(tenantId); return kernel.GetChainIdBySessionAsync(tenantId, sessionId); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs index 93e6dbd8..4090237f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs @@ -30,7 +30,7 @@ public DefaultSessionRefreshService( public async Task RefreshAsync(SessionValidationResult validation, DateTimeOffset now, CancellationToken ct = default) { if (!validation.IsValid) - return SessionRefreshResult.Failed(); + return SessionRefreshResult.ReauthRequired(); var session = validation.Session; bool didTouch = false; diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index f23c8005..834067b0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; using Microsoft.AspNetCore.Http; +using System.Security.Claims; namespace CodeBeam.UltimateAuth.Server.Services { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs index 5fd5ff70..688fdb94 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs @@ -5,8 +5,7 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory { internal static class InMemoryCredentialSeeder { - public static IReadOnlyCollection CreateDefaultUsers( - IUAuthPasswordHasher passwordHasher) + public static IReadOnlyCollection CreateDefaultUsers(IUAuthPasswordHasher passwordHasher) { var adminUserId = UserId.New(); @@ -14,7 +13,7 @@ public static IReadOnlyCollection CreateDefaultUsers( var admin = new InMemoryCredentialUser( userId: adminUserId, - username: "admin", + username: "Admin", passwordHash: passwordHash, securityVersion: 0, isActive: true diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs new file mode 100644 index 00000000..20da8c8b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs @@ -0,0 +1,91 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Fake; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client +{ + public sealed class BlazorServerSessionCoordinatorTests + { + [Fact] + public async Task StartAsync_MarksStarted_AndAutomaticRefresh() + { + var diagnostics = new UAuthClientDiagnostics(); + var client = new FakeUAuthClient(RefreshOutcome.NoOp); + var nav = new TestNavigationManager(); + + var options = Options.Create(new UAuthClientOptions + { + Refresh = { Interval = TimeSpan.FromMilliseconds(10) } + }); + + var coordinator = new BlazorServerSessionCoordinator( + client, + nav, + options, + diagnostics); + + await coordinator.StartAsync(); + await Task.Delay(30); + await coordinator.StopAsync(); + + Assert.Equal(1, diagnostics.StartCount); + Assert.True(diagnostics.AutomaticRefreshCount >= 1); + } + + [Fact] + public async Task ReauthRequired_ShouldTerminateAndNavigate() + { + var diagnostics = new UAuthClientDiagnostics(); + var client = new FakeUAuthClient(RefreshOutcome.ReauthRequired); + var nav = new TestNavigationManager(); + + var options = Options.Create(new UAuthClientOptions + { + Refresh = { Interval = TimeSpan.FromMilliseconds(5) }, + Reauth = + { + Behavior = ReauthBehavior.RedirectToLogin, + LoginPath = "/login" + } + }); + + var coordinator = new BlazorServerSessionCoordinator( + client, + nav, + options, + diagnostics); + + await coordinator.StartAsync(); + await Task.Delay(20); + + Assert.True(diagnostics.IsTerminated); + Assert.Equal(CoordinatorTerminationReason.ReauthRequired, diagnostics.TerminationReason); + Assert.Equal("/login", nav.LastNavigatedTo); + } + + [Fact] + public async Task StopAsync_ShouldMarkStopped() + { + var diagnostics = new UAuthClientDiagnostics(); + var client = new FakeUAuthClient(); + var nav = new TestNavigationManager(); + + var options = Options.Create(new UAuthClientOptions()); + + var coordinator = new BlazorServerSessionCoordinator( + client, + nav, + options, + diagnostics); + + await coordinator.StartAsync(); + await coordinator.StopAsync(); + + Assert.Equal(1, diagnostics.StopCount); + } + } +} 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..054bd22c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs @@ -0,0 +1,106 @@ +using System; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using Xunit; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client +{ + 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.MarkRefreshUnknown(); + + Assert.Equal(1, diagnostics.RefreshTouchedCount); + Assert.Equal(1, diagnostics.RefreshNoOpCount); + Assert.Equal(1, diagnostics.RefreshReauthRequiredCount); + Assert.Equal(1, diagnostics.RefreshUnknownCount); + } + + [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/RefreshOutcomeParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs new file mode 100644 index 00000000..e0725041 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Domain; +using Xunit; + +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.None, result); + } + } +} 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..aa80e271 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeNavigationManager.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Fake +{ + 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/Fake/FakeUAuthClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs new file mode 100644 index 00000000..bba4e93f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit +{ + internal sealed class FakeUAuthClient : IUAuthClient + { + private readonly Queue _outcomes; + + public FakeUAuthClient(params RefreshOutcome[] outcomes) + { + _outcomes = new Queue(outcomes); + } + + public Task LoginAsync(LoginRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutAsync() + { + throw new NotImplementedException(); + } + + public Task ReauthAsync() + { + throw new NotImplementedException(); + } + + public Task RefreshAsync(bool isAuto = false) + { + var outcome = _outcomes.Count > 0 + ? _outcomes.Dequeue() + : RefreshOutcome.None; + + return Task.FromResult(new RefreshResult + { + Ok = true, + Outcome = outcome + }); + } + + public Task ValidateAsync() + { + throw new NotImplementedException(); + } + } + +} From ba2c2af3ab44fa70c5aeee2b1311192510acde19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:40:52 +0300 Subject: [PATCH 21/50] Preparation of First Release (Part 4/7) (#12) * Create UAuthHub and ResourceApi Projects * Arrange Program.cs of WASM & Hub & Api * WASM Login & Validation & Logout Flows * Add Multi-Client Support * Push Before Going Complex * Start Fixing Bugs in Blazor Server * Fix Programmatic Login and AuthenticationStateProvider for Blazor Server * Fix Refresh on Blazor Server * UAuthState Implementation --- UltimateAuth.slnx | 6 +- ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 25 + .../Components/App.razor | 30 + .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 98 + .../Components/Layout/ReconnectModal.razor | 31 + .../Layout/ReconnectModal.razor.css | 157 + .../Components/Layout/ReconnectModal.razor.js | 63 + .../Components}/Pages/Counter.razor | 0 .../Components/Pages/Home.razor | 7 + .../Components/Pages/NotFound.razor | 5 + .../Components/Routes.razor | 6 + .../Components/_Imports.razor | 11 + .../Program.cs | 91 + .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 0 .../appsettings.json | 0 .../wwwroot/app.css | 60 + .../wwwroot/favicon.png | Bin ...eam.UltimateAuth.Sample.BlazorServer.slnx} | 0 ...m.UltimateAuth.Sample.BlazorServer.csproj} | 6 +- .../Components/App.razor | 1 - .../Components/Layout/MainLayout.razor | 0 .../Components/Layout/MainLayout.razor.cs | 2 +- .../Components/Layout/MainLayout.razor.css | 0 .../Components/Pages/Error.razor | 0 .../Components/Pages/Home.razor | 8 +- .../Components/Pages/Home.razor.cs | 111 + .../Components/Routes.razor | 0 .../Components/_Imports.razor | 0 .../Program.cs | 10 +- .../Properties/launchSettings.json | 0 .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../wwwroot/app.css | 0 .../wwwroot/favicon.png | Bin .../Pages/UAuthAuthenticationStateProvider.cs | 22 - .../wwwroot/bootstrap/bootstrap.min.css | 7 - .../wwwroot/bootstrap/bootstrap.min.css.map | 1 - .../App.razor | 21 + ...teAuth.Sample.BlazorStandaloneWasm.csproj} | 10 +- .../Layout/MainLayout.razor | 4 + .../Layout/MainLayout.razor.css | 0 .../Pages/Counter.razor | 18 + .../Pages/Home.razor | 132 + .../Pages/Home.razor.cs | 12 +- .../Pages/Weather.razor | 0 .../Program.cs | 41 + .../Properties/launchSettings.json | 4 +- .../_Imports.razor | 7 + .../wwwroot/css/app.css | 0 .../wwwroot/favicon.png | Bin 0 -> 1148 bytes .../wwwroot/icon-192.png | Bin .../wwwroot/index.html | 8 +- .../wwwroot/sample-data/weather.json | 0 .../App.razor | 12 - .../Layout/MainLayout.razor | 16 - .../Layout/NavMenu.razor | 39 - .../Layout/NavMenu.razor.css | 83 - .../Pages/Home.razor | 12 - .../Program.cs | 16 - .../lib/bootstrap/dist/css/bootstrap-grid.css | 4085 ------ .../bootstrap/dist/css/bootstrap-grid.css.map | 1 - .../bootstrap/dist/css/bootstrap-grid.min.css | 6 - .../dist/css/bootstrap-grid.min.css.map | 1 - .../bootstrap/dist/css/bootstrap-grid.rtl.css | 4084 ------ .../dist/css/bootstrap-grid.rtl.css.map | 1 - .../dist/css/bootstrap-grid.rtl.min.css | 6 - .../dist/css/bootstrap-grid.rtl.min.css.map | 1 - .../bootstrap/dist/css/bootstrap-reboot.css | 597 - .../dist/css/bootstrap-reboot.css.map | 1 - .../dist/css/bootstrap-reboot.min.css | 6 - .../dist/css/bootstrap-reboot.min.css.map | 1 - .../dist/css/bootstrap-reboot.rtl.css | 594 - .../dist/css/bootstrap-reboot.rtl.css.map | 1 - .../dist/css/bootstrap-reboot.rtl.min.css | 6 - .../dist/css/bootstrap-reboot.rtl.min.css.map | 1 - .../dist/css/bootstrap-utilities.css | 5402 ------- .../dist/css/bootstrap-utilities.css.map | 1 - .../dist/css/bootstrap-utilities.min.css | 6 - .../dist/css/bootstrap-utilities.min.css.map | 1 - .../dist/css/bootstrap-utilities.rtl.css | 5393 ------- .../dist/css/bootstrap-utilities.rtl.css.map | 1 - .../dist/css/bootstrap-utilities.rtl.min.css | 6 - .../css/bootstrap-utilities.rtl.min.css.map | 1 - .../lib/bootstrap/dist/css/bootstrap.css | 12057 ---------------- .../lib/bootstrap/dist/css/bootstrap.css.map | 1 - .../lib/bootstrap/dist/css/bootstrap.min.css | 6 - .../bootstrap/dist/css/bootstrap.min.css.map | 1 - .../lib/bootstrap/dist/css/bootstrap.rtl.css | 12030 --------------- .../bootstrap/dist/css/bootstrap.rtl.css.map | 1 - .../bootstrap/dist/css/bootstrap.rtl.min.css | 6 - .../dist/css/bootstrap.rtl.min.css.map | 1 - .../lib/bootstrap/dist/js/bootstrap.bundle.js | 6314 -------- .../bootstrap/dist/js/bootstrap.bundle.js.map | 1 - .../bootstrap/dist/js/bootstrap.bundle.min.js | 7 - .../dist/js/bootstrap.bundle.min.js.map | 1 - .../lib/bootstrap/dist/js/bootstrap.esm.js | 4447 ------ .../bootstrap/dist/js/bootstrap.esm.js.map | 1 - .../bootstrap/dist/js/bootstrap.esm.min.js | 7 - .../dist/js/bootstrap.esm.min.js.map | 1 - .../lib/bootstrap/dist/js/bootstrap.js | 4494 ------ .../lib/bootstrap/dist/js/bootstrap.js.map | 1 - .../lib/bootstrap/dist/js/bootstrap.min.js | 7 - .../bootstrap/dist/js/bootstrap.min.js.map | 1 - ...eam.UltimateAuth.Sample.ResourceApi.csproj | 17 + ...eBeam.UltimateAuth.Sample.ResourceApi.http | 6 + .../Controllers/WeatherForecastController.cs | 26 + .../Program.cs | 83 + .../Properties/launchSettings.json | 23 + .../WeatherForecast.cs | 13 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../Abstractions/IBrowserPostClient.cs | 4 +- .../Authentication/.gitkeep | 1 - .../DefaultUAuthStateManager.cs | 69 + .../Authentication/IUAuthStateManager.cs | 36 + .../UAuthAuthenticatonStateProvider.cs | 31 + .../Authentication/UAuthState.cs | 113 + .../Authentication/UAuthStateChangeReason.cs | 10 + .../CodeBeam.UltimateAuth.Client.csproj | 11 +- .../Components/UALoginForm.razor | 11 +- .../Components/UALoginForm.razor.cs | 38 +- ...teAuthClientServiceCollectionExtensions.cs | 29 +- .../Infrastructure/BrowserPostClient.cs | 37 +- .../Infrastructure/ClientClock.cs | 8 + .../Infrastructure/UAuthUrlBuilder.cs | 10 + .../Options/UAuthClientOptions.cs | 5 + .../Options/UAuthClientProfileDetector.cs | 14 +- .../Options/UAuthOptionsPostConfigure.cs | 28 + .../Services/IUAuthClient.cs | 2 +- .../Services/UAuthClient.cs | 25 +- .../wwwroot/uauth.js | 72 +- .../Infrastructure/IRefreshTokenResolver.cs | 10 - .../Abstractions/Services/IUAuthService.cs | 4 +- .../Stores/IAccessTokenIdStore.cs | 26 + .../Abstractions/Stores/IOpaqueTokenStore.cs | 11 - .../Abstractions/Stores/IRefreshTokenStore.cs | 38 + .../Abstractions/Stores/ITokenStore.cs | 76 - .../Abstractions/Stores/ITokenStoreFactory.cs | 7 - .../Abstractions/Stores/ITokenStoreKernel.cs | 47 - .../{ITokenValidator.cs => IJwtValidator.cs} | 7 +- .../Validators/IRefreshTokenValidator.cs | 12 + .../AssemblyVisibility.cs | 1 + .../Contracts/Login/LoginRequest.cs | 2 +- .../Contracts/Session/AuthStateSnapshot.cs | 15 + .../Contracts/Session/AuthValidationResult.cs | 18 +- .../Token/RefreshTokenRotationContext.cs | 7 + .../Token/RefreshTokenRotationResult.cs | 24 + .../Token/RefreshTokenValidationContext.cs | 12 + .../Token/RefreshTokenValidationResult.cs | 48 +- .../Contracts/Token/TokenFormat.cs | 9 + .../Contracts/Token}/TokenIssuanceContext.cs | 4 +- .../Contracts/User/AuthUserSnapshot.cs | 18 + .../Domain/AuthFlowType.cs | 25 + .../Domain/Principals/CredentialKind.cs | 9 + .../Domain/Session/ClaimsSnapshot.cs | 63 +- .../Domain/Session/RefreshOutcome.cs | 1 + .../SessionRefreshStatus.cs | 0 .../Domain/Token/StoredRefreshToken.cs | 20 +- .../Extensions/ClaimSnapshotExtensions.cs | 45 + .../DefaultRefreshTokenValidator.cs | 51 + .../Infrastructure/NoOpAccessTokenIdStore.cs | 16 + .../UAuthRefreshTokenResolver.cs | 75 - .../Options}/HeaderTokenFormat.cs | 2 +- .../Token => Options}/TokenResponseMode.cs | 2 +- .../Options/UAuthClientProfile.cs | 2 +- .../Options/UAuthMultiTenantOptions.cs | 16 + .../Options/UAuthPkceOptions.cs | 10 +- .../Options/UAuthSessionOptions.cs | 20 +- .../Options/UAuthTokenOptions.cs | 15 + .../Abstractions/ICredentialResolver.cs | 13 - .../Abstractions/ICredentialResponseWriter.cs | 4 +- .../Abstractions/IRefreshTokenResolver.cs | 13 + .../Abstractions/ITokenIssuer.cs | 15 + .../AssemblyVisibility.cs | 3 + .../DefaultAuthFlowContextAccessor.cs | 14 + .../Auth/Accessor/IAuthFlowContextAccessor.cs | 8 + .../Auth/Context/AuthFlowContext.cs | 64 + .../Auth/Context/AuthFlowContextFactory.cs | 66 + .../Auth/Context/AuthFlowEndpointFilter.cs | 27 + .../Auth/Context/AuthFlowMetadata.cs | 14 + .../Auth/Context/DefaultAuthFlow.cs | 33 + .../Auth/Context/IAuthFlow.cs | 9 + .../Auth/DefaultClientProfileReader.cs | 33 + .../DefaultEffectiveServerOptionsProvider.cs | 47 + .../Auth/DefaultPrimaryTokenResolver.cs | 21 + .../Auth/EffectiveUAuthServerOptions.cs | 17 + .../Auth/IClientProfileReader.cs | 10 + .../Auth/IPrimaryTokenResolver.cs | 10 + ...AuthResponseOptionsModeTemplateResolver.cs | 135 + .../ClientProfileAuthResponseAdapter.cs | 55 + .../Response/DefaultAuthResponseResolver.cs | 94 + .../DefaultEffectiveAuthModeResolver.cs | 26 + .../Auth/Response/EffectiveAuthResponse.cs | 12 + .../EffectiveLoginRedirectResponse.cs | 14 + .../EffectiveLogoutRedirectResponse.cs | 9 + .../Auth/Response/IAuthResponseResolver.cs | 11 + .../Response/IEffectiveAuthModeResolver.cs | 11 + ...cs => UAuthAuthenticationCookieOptions.cs} | 2 +- .../UAuthAuthenticationExtension.cs | 2 +- .../UAuthAuthenticationHandler.cs | 41 +- .../Contracts/RefreshTokenStatus.cs | 12 + .../Cookies/DefaultUAuthCookieManager.cs | 21 + .../DefaultUAuthCookiePolicyBuilder.cs | 70 + .../Cookies/IUAuthCookieManager.cs | 8 +- .../Cookies/IUAuthCookiePolicyBuilder.cs | 11 + .../Cookies/UAuthSessionCookieManager.cs | 91 - .../Diagnostics/UAuthDiagnostic.cs | 2 +- .../Diagnostics/UAuthStartupDiagnostics.cs | 48 +- .../Endpoints/DefaultLoginEndpointHandler.cs | 129 +- .../Endpoints/DefaultLogoutEndpointHandler.cs | 63 +- .../DefaultRefreshEndpointHandler.cs | 121 +- .../DefaultValidateEndpointHandler.cs | 27 +- .../Endpoints/EndpointEnablement.cs | 8 - .../Endpoints/UAuthEndpointDefaultsMap.cs | 63 - .../Endpoints/UAuthEndpointRegistrar.cs | 57 +- .../Extensions/HttpContextUserExtensions.cs | 20 + .../UAuthServerOptionsExtensions.cs | 13 + .../UAuthServerServiceCollectionExtensions.cs | 84 +- .../Infrastructure/AuthRedirectResolver.cs | 65 + .../CompositeSessionIdResolver.cs | 56 - .../DefaultTransportCredentialResolver.cs | 169 + .../ITransportCredentialResolver.cs | 9 + .../AspNetCore/TransportCredential.cs | 13 + .../AspNetCore/TransportCredentialKind.cs | 10 + .../DefaultFlowCredentialResolver.cs | 93 + .../DefaultUAuthBodyPolicyBuilder.cs | 13 + .../DefaultUAuthHeaderPolicyBuilder.cs | 23 + .../Credentials/IFlowCredentialResolver.cs | 15 + .../Credentials/IUAuthBodyPolicyBuilder.cs | 9 + .../Credentials/IUAuthHeaderPolicyBuilder.cs | 9 + .../DefaultCredentialResolver.cs | 77 - .../DefaultCredentialResponseWriter.cs | 120 +- .../Infrastructure/IInnerSessionIdResolver.cs | 1 + .../Infrastructure/ITokenIssuer.cs | 14 - .../Orchestrator/UAuthSessionQueryService.cs | 7 +- .../Refresh/DefaultRefreshTokenResolver.cs | 44 + .../Infrastructure/Refresh/RefreshDecision.cs | 21 +- .../Refresh/RefreshDecisionResolver.cs | 17 +- .../BearerSessionIdResolver.cs | 3 + .../SessionId/CompositeSessionIdResolver.cs | 28 + .../CookieSessionIdResolver.cs | 7 +- .../HeaderSessionIdResolver.cs | 1 + .../{ => SessionId}/QuerySessionIdResolver.cs | 1 + .../Infrastructure/UAuthUserAccessor.cs | 28 +- .../Issuers/UAuthTokenIssuer.cs | 65 +- .../Options/AuthResponseOptions.cs | 14 +- .../Options/CredentialResponseOptions.cs | 31 +- .../Options/Defaults/ConfigureDefaults.cs | 124 +- .../IEffectiveServerOptionsProvider.cs | 13 + .../Options/LoginRedirectOptions.cs | 12 +- .../Options/LogoutRedirectOptions.cs | 9 +- .../Options/PrimaryCredentialPolicy.cs | 7 + .../Options/UAuthCookieLifetimeOptions.cs | 25 + .../Options/UAuthCookieOptions.cs | 33 +- .../Options/UAuthCookiePolicyResolver.cs | 20 - .../Options/UAuthCookieSetOptions.cs | 40 + .../Options/UAuthDiagnosticsOptions.cs | 10 +- .../Options/UAuthHubOptions.cs | 18 + .../Options/UAuthServerOptions.cs | 45 +- .../Options/UAuthServerOptionsValidator.cs | 5 +- .../Options/UAuthServerProfileDetector.cs | 4 +- .../Options/UAuthSessionResolutionOptions.cs | 12 + .../Services/IRefreshTokenRotationService.cs | 10 + .../Services/IUAuthFlowService.cs | 7 +- .../Services/IUAuthTokenService.cs | 11 +- .../Services/RefreshTokenRotationService.cs | 72 + .../Services/UAuthFlowService.cs | 79 +- .../Services/UAuthJwtValidator.cs | 85 + .../Services/UAuthTokenService.cs | 21 +- .../Services/UAuthTokenValidator.cs | 165 - .../EfCoreTokenStore.cs | 244 +- .../EfCoreTokenStoreKernel.cs | 69 - .../Projections/RefreshTokenProjection.cs | 7 +- .../ServiceCollectionExtensions.cs | 3 +- .../UAuthTokenDbContext.cs | 13 +- .../InMemoryRefreshTokenStore.cs | 123 + .../InMemoryTokenStore.cs | 96 - .../InMemoryTokenStoreFactory.cs | 18 - .../InMemoryTokenStoreKernel.cs | 77 - .../ServiceCollectionExtensions.cs | 3 +- .../Fake/FakeUAuthClient.cs | 6 + .../Server/EffectiveAuthModeResolverTests.cs | 40 + .../EffectiveServerOptionsProviderTests.cs | 146 + .../TestHelpers.cs | 14 + 286 files changed, 4985 insertions(+), 61682 deletions(-) create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor.css create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor.js rename samples/{blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm => UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components}/Pages/Counter.razor (100%) create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotFound.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Properties/launchSettings.json rename samples/{blazor-server/UltimateAuth.Sample.BlazorServer => UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub}/appsettings.Development.json (100%) rename samples/{blazor-server/UltimateAuth.Sample.BlazorServer => UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub}/appsettings.json (100%) create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css rename samples/{blazor-server/UltimateAuth.Sample.BlazorServer => UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub}/wwwroot/favicon.png (100%) rename samples/blazor-server/{UltimateAuth.BlazorServer.slnx => CodeBeam.UltimateAuth.Sample.BlazorServer.slnx} (100%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer/UltimateAuth.Sample.BlazorServer.csproj => CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj} (89%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Components/App.razor (94%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Components/Layout/MainLayout.razor (100%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Components/Layout/MainLayout.razor.cs (76%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Components/Layout/MainLayout.razor.css (100%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Components/Pages/Error.razor (100%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Components/Pages/Home.razor (90%) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Components/Routes.razor (100%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Components/_Imports.razor (100%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Program.cs (90%) rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/Properties/launchSettings.json (100%) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/appsettings.Development.json create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/appsettings.json rename samples/blazor-server/{UltimateAuth.Sample.BlazorServer => CodeBeam.UltimateAuth.Sample.BlazorServer}/wwwroot/app.css (100%) rename samples/{blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm => blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer}/wwwroot/favicon.png (100%) delete mode 100644 samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/UAuthAuthenticationStateProvider.cs delete mode 100644 samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css delete mode 100644 samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm/UltimateAuth.Sample.BlazorStandaloneWasm.csproj => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj} (57%) create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/Layout/MainLayout.razor.css (100%) create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor rename samples/{blazor-server/UltimateAuth.Sample.BlazorServer/Components => blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/Pages/Home.razor.cs (88%) rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/Pages/Weather.razor (100%) create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/Properties/launchSettings.json (85%) rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/_Imports.razor (72%) rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/wwwroot/css/app.css (100%) create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/wwwroot/icon-192.png (100%) rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/wwwroot/index.html (64%) rename samples/blazor-standalone-wasm/{UltimateAuth.Sample.BlazorStandaloneWasm => CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm}/wwwroot/sample-data/weather.json (100%) delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/App.razor delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.js delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js delete mode 100644 samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.http create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Controllers/WeatherForecastController.cs create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Properties/launchSettings.json create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/WeatherForecast.cs create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/appsettings.Development.json create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/appsettings.json delete mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/.gitkeep create mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs rename src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/{ITokenValidator.cs => IJwtValidator.cs} (69%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs rename src/{CodeBeam.UltimateAuth.Server/Infrastructure => CodeBeam.UltimateAuth.Core/Contracts/Token}/TokenIssuanceContext.cs (79%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs rename src/CodeBeam.UltimateAuth.Core/Domain/{Token => Session}/SessionRefreshStatus.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs rename src/{CodeBeam.UltimateAuth.Server/Contracts => CodeBeam.UltimateAuth.Core/Options}/HeaderTokenFormat.cs (60%) rename src/CodeBeam.UltimateAuth.Core/{Domain/Token => Options}/TokenResponseMode.cs (69%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowMetadata.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/DefaultClientProfileReader.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/DefaultEffectiveServerOptionsProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/DefaultPrimaryTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultAuthResponseResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultEffectiveAuthModeResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs rename src/CodeBeam.UltimateAuth.Server/Authentication/{UAuthCookieOptions.cs => UAuthAuthenticationCookieOptions.cs} (70%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookieManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/EndpointEnablement.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultFlowCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthBodyPolicyBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthHeaderPolicyBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IUAuthBodyPolicyBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IUAuthHeaderPolicyBuilder.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => SessionId}/BearerSessionIdResolver.cs (94%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => SessionId}/CookieSessionIdResolver.cs (80%) rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => SessionId}/HeaderSessionIdResolver.cs (95%) rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => SessionId}/QuerySessionIdResolver.cs (95%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs rename src/{CodeBeam.UltimateAuth.Core/Abstractions => CodeBeam.UltimateAuth.Server}/Services/IUAuthFlowService.cs (77%) rename src/{CodeBeam.UltimateAuth.Core/Abstractions => CodeBeam.UltimateAuth.Server}/Services/IUAuthTokenService.cs (52%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs delete mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs delete mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs delete mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreFactory.cs delete mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreKernel.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 99d138b0..ee6b5d95 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,7 +1,9 @@ - - + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj new file mode 100644 index 00000000..e91af37b --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + 0.0.1-preview + true + + + + + + + + + + + + + + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor new file mode 100644 index 00000000..0720040e --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor new file mode 100644 index 00000000..96fbbe6c --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000..38d1f259 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css @@ -0,0 +1,98 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#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/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor new file mode 100644 index 00000000..49d916bc --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/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 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/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Counter.razor similarity index 100% rename from samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Counter.razor 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..9001e0bd --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new app. 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..105855d4 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + 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..741144cf --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/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 UltimateAuth.Sample.UAuthHub +@using UltimateAuth.Sample.UAuthHub.Components +@using UltimateAuth.Sample.UAuthHub.Components.Layout 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..f7a8d49d --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -0,0 +1,91 @@ +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Security.Argon2; +using CodeBeam.UltimateAuth.Server.Authentication; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Sessions.InMemory; +using CodeBeam.UltimateAuth.Tokens.InMemory; +using MudBlazor.Services; +using MudExtensions.Services; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddMudServices(); +builder.Services.AddMudExtensions(); + +builder.Services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = UAuthCookieDefaults.AuthenticationScheme; + options.DefaultSignInScheme = UAuthCookieDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = UAuthCookieDefaults.AuthenticationScheme; + }) + .AddUAuthCookies(); + +builder.Services.AddAuthorization(); + +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddUltimateAuth(); + +builder.Services.AddUltimateAuthServer(o => { + o.Diagnostics.EnableRefreshHeaders = true; + //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); + //o.Session.TouchInterval = TimeSpan.FromSeconds(9); + //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); +}) + .AddInMemoryCredentials() + .AddUltimateAuthInMemorySessions() + .AddUltimateAuthInMemoryTokens() + .AddUltimateAuthArgon2(); + +builder.Services.AddCors(options => +{ + options.AddPolicy("WasmSample", policy => + { + policy + .WithOrigins("https://localhost:6130") + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); +app.UseCors("WasmSample"); + +app.UseUltimateAuthServer(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapUAuthEndpoints(); +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.MapGet("/health", () => +{ + return Results.Ok(new + { + service = "UAuthHub", + status = "ok" + }); +}); + +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/blazor-server/UltimateAuth.Sample.BlazorServer/appsettings.Development.json b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.Development.json similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/appsettings.Development.json rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.Development.json diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/appsettings.json b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.json similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/appsettings.json rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.json 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..73a69d6f --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css @@ -0,0 +1,60 @@ +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; +} + +.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; +} \ No newline at end of file diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/favicon.png b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/favicon.png similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/favicon.png rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/favicon.png diff --git a/samples/blazor-server/UltimateAuth.BlazorServer.slnx b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.slnx similarity index 100% rename from samples/blazor-server/UltimateAuth.BlazorServer.slnx rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.slnx diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/UltimateAuth.Sample.BlazorServer.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj similarity index 89% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/UltimateAuth.Sample.BlazorServer.csproj rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj index d4044a07..69effd56 100644 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/UltimateAuth.Sample.BlazorServer.csproj +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj @@ -1,15 +1,15 @@  - net8.0 + net10.0 enable enable 0.0.1-preview - - + + diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/App.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor similarity index 94% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/App.razor rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor index 9f3c4bac..7e2b73d4 100644 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/App.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor @@ -5,7 +5,6 @@ - diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs similarity index 76% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs index df17c947..389a4569 100644 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs @@ -1,6 +1,6 @@ using MudBlazor; -namespace UltimateAuth.Sample.BlazorServer.Components.Layout +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Layout { public partial class MainLayout { diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor similarity index 90% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 668af392..b9a0c20e 100644 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -1,16 +1,19 @@ @page "/" @page "/login" @using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Authentication @using CodeBeam.UltimateAuth.Client.Diagnostics @using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Runtime @using CodeBeam.UltimateAuth.Server.Abstractions @using CodeBeam.UltimateAuth.Server.Cookies @using CodeBeam.UltimateAuth.Server.Infrastructure +@using CodeBeam.UltimateAuth.Server.Services +@inject IUAuthStateManager StateManager @inject IUAuthFlowService Flow @inject ISnackbar Snackbar @inject ISessionQueryService SessionQuery -@inject ICredentialResolver CredentialResolver +@inject IFlowCredentialResolver CredentialResolver @inject IClock Clock @inject IUAuthCookieManager CookieManager @inject IHttpContextAccessor HttpContextAccessor @@ -50,9 +53,10 @@ State of Authentication: @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name) + UAuthState @(StateManager.State.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(StateManager.State.UserId) - Authorized context is shown. + Authorized context is shown. @context.User.Identity.IsAuthenticated Not Authorized context is shown. 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..c3d3682a --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -0,0 +1,111 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.AspNetCore.Components.Authorization; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages +{ + public partial class Home + { + private string? _username; + private string? _password; + + private UALoginForm _form = null!; + + private AuthenticationState _authState = null!; + + protected override async Task OnInitializedAsync() + { + Diagnostics.Changed += OnDiagnosticsChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await StateManager.EnsureAsync(); + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + StateHasChanged(); + } + } + + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } + + private async Task ProgrammaticLogin() + { + var request = new LoginRequest + { + Identifier = "Admin", + Secret = "Password!", + }; + await UAuthClient.LoginAsync(request); + await UAuthClient.ValidateAsync(); + await StateManager.EnsureAsync(); + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + } + + private async Task ValidateAsync() + { + var result = await UAuthClient.ValidateAsync(); + + Snackbar.Add( + result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", + result.IsValid ? Severity.Success : Severity.Error); + } + + private async Task LogoutAsync() + { + await UAuthClient.LogoutAsync(); + Snackbar.Add("Logged out", Severity.Success); + } + + private async Task RefreshAsync() + { + await UAuthClient.RefreshAsync(); + } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("error", out var error)) + { + ShowLoginError(error.ToString()); + ClearQueryString(); + } + } + } + + private void ShowLoginError(string code) + { + var message = code switch + { + "invalid" => "Invalid username or password.", + "locked" => "Your account is locked.", + "mfa" => "Multi-factor authentication required.", + _ => "Login failed." + }; + + Snackbar.Add(message, Severity.Error); + } + + private void ClearQueryString() + { + var uri = new Uri(Nav.Uri); + var clean = uri.GetLeftPart(UriPartial.Path); + Nav.NavigateTo(clean, replace: true); + } + + public void Dispose() + { + Diagnostics.Changed -= OnDiagnosticsChanged; + } + + } +} diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Routes.razor rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/_Imports.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/_Imports.razor rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs similarity index 90% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Program.cs rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 81598dff..e449e4e4 100644 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,20 +1,16 @@ -using CodeBeam.UltimateAuth.Client.BlazorServer; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Authentication; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.Server; using MudBlazor.Services; using MudExtensions.Services; -using UltimateAuth.Sample.BlazorServer.Components; var builder = WebApplication.CreateBuilder(args); @@ -40,8 +36,6 @@ builder.Services.AddAuthorization(); -builder.Services.AddHttpContextAccessor(); - builder.Services.AddUltimateAuth(); builder.Services.AddUltimateAuthServer(o => { @@ -61,8 +55,6 @@ o.Reauth.Behavior = ReauthBehavior.RaiseEvent; }); - - builder.Services.AddScoped(sp => { var navigation = sp.GetRequiredService(); diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json 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/UltimateAuth.Sample.BlazorServer/wwwroot/app.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css similarity index 100% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/app.css rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/favicon.png similarity index 100% rename from samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/favicon.png diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/UAuthAuthenticationStateProvider.cs b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/UAuthAuthenticationStateProvider.cs deleted file mode 100644 index 5319d4de..00000000 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/UAuthAuthenticationStateProvider.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Components.Authorization; - -namespace CodeBeam.UltimateAuth.Client.BlazorServer; - -internal sealed class UAuthAuthenticationStateProvider : AuthenticationStateProvider -{ - private readonly AuthenticationStateProvider _inner; - - public UAuthAuthenticationStateProvider(AuthenticationStateProvider inner) - { - _inner = inner; - - _inner.AuthenticationStateChanged += s => NotifyAuthenticationStateChanged(s); - } - - public override Task GetAuthenticationStateAsync() => _inner.GetAuthenticationStateAsync(); - - /// - /// Call this after login/logout navigation - /// - public void NotifyStateChanged() => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); -} diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css deleted file mode 100644 index 02ae65b5..00000000 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css +++ /dev/null @@ -1,7 +0,0 @@ -@charset "UTF-8";/*! - * Bootstrap v5.1.0 (https://getbootstrap.com/) - * Copyright 2011-2021 The Bootstrap Authors - * Copyright 2011-2021 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root{--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-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-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-rgb:33,37,41;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",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-bg:#fff}*,::after,::before{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:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],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}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,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]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button: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:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-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}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);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}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(var(--bs-gutter-y) * -1);margin-right:calc(var(--bs-gutter-x) * -.5);margin-left:calc(var(--bs-gutter-x) * -.5)}.row>*{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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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.3333333333%}.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.6666666667%}.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}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:last-child)>:last-child>*{border-bottom-color:currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-striped>tbody>tr:nth-of-type(odd){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:calc(3 / 4 * 100%)}.ratio-16x9{--bs-aspect-ratio:calc(9 / 16 * 100%)}.ratio-21x9{--bs-aspect-ratio:calc(9 / 21 * 100%)}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display: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}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!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}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!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}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.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-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}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!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}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.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-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}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!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}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.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-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}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!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}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.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-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}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!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}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.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-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}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!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}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!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-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.min.css.map */ \ No newline at end of file diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map b/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map deleted file mode 100644 index afcd9e33..00000000 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/wwwroot/bootstrap/bootstrap.min.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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","sourcesContent":["/*!\n * Bootstrap v5.1.0 (https://getbootstrap.com/)\n * Copyright 2011-2021 The Bootstrap Authors\n * Copyright 2011-2021 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n\n// scss-docs-start import-stack\n// Configuration\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"utilities\";\n\n// Layout & components\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"containers\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"accordion\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"alert\";\n@import \"progress\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"offcanvas\";\n@import \"placeholders\";\n\n// Helpers\n@import \"helpers\";\n\n// Utilities\n@import \"utilities/api\";\n// scss-docs-end import-stack\n",":root {\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 --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$variable-prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$variable-prefix}#{$color}-rgb: #{$value};\n }\n\n --#{$variable-prefix}white-rgb: #{to-rgb($white)};\n --#{$variable-prefix}black-rgb: #{to-rgb($black)};\n --#{$variable-prefix}body-rgb: #{to-rgb($body-color)};\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 --#{$variable-prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$variable-prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$variable-prefix}gradient: #{$gradient};\n\n // Root and body\n // stylelint-disable custom-property-empty-line-before\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$variable-prefix}root-font-size: #{$font-size-root};\n }\n --#{$variable-prefix}body-font-family: #{$font-family-base};\n --#{$variable-prefix}body-font-size: #{$font-size-base};\n --#{$variable-prefix}body-font-weight: #{$font-weight-base};\n --#{$variable-prefix}body-line-height: #{$line-height-base};\n --#{$variable-prefix}body-color: #{$body-color};\n @if $body-text-align != null {\n --#{$variable-prefix}body-text-align: #{$body-text-align};\n }\n --#{$variable-prefix}body-bg: #{$body-bg};\n // scss-docs-end root-body-variables\n // stylelint-enable custom-property-empty-line-before\n}\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 font-size: var(--#{$variable-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(--#{$variable-prefix}body-font-family);\n @include font-size(var(--#{$variable-prefix}body-font-size));\n font-weight: var(--#{$variable-prefix}body-font-weight);\n line-height: var(--#{$variable-prefix}body-line-height);\n color: var(--#{$variable-prefix}body-color);\n text-align: var(--#{$variable-prefix}body-text-align);\n background-color: var(--#{$variable-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// 2. Set correct height and prevent the `size` attribute to make the `hr` look like an input field\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n background-color: currentColor;\n border: 0;\n opacity: $hr-opacity;\n}\n\nhr:not([size]) {\n height: $hr-height; // 2\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: $headings-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. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\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 background-color: $mark-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: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\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 direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\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: $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-` + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} 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..fb58035e --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -0,0 +1,132 @@ +@page "/" +@page "/login" +@using CodeBeam.UltimateAuth.Client.Authentication +@using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Core.Runtime +@inject IUAuthStateManager StateManager +@inject IHttpClientFactory HttpClientFactory +@inject IUAuthProductInfoProvider ProductInfo +@inject ISnackbar Snackbar +@inject IUAuthClient UAuthClient +@inject NavigationManager Nav +@inject IUAuthProductInfoProvider ProductInfo +@inject AuthenticationStateProvider AuthStateProvider +@inject UAuthClientDiagnostics Diagnostics + +

+ +Ping UAuthHub +Ping ResourceApi + +@code { + private string? _result; + private Severity _severity = Severity.Info; + + private async Task CallHub() + { + try + { + var client = HttpClientFactory.CreateClient("UAuthHub"); + var response = await client.GetStringAsync("/health"); + + _result = $"UAuthHub response: {response}"; + _severity = Severity.Success; + } + catch (Exception ex) + { + _result = $"UAuthHub error: {ex.Message}"; + _severity = Severity.Error; + } + Snackbar.Add(_result, _severity); + } + + private async Task CallApi() + { + try + { + var client = HttpClientFactory.CreateClient("ResourceApi"); + var response = await client.GetStringAsync("/health"); + + _result = $"ResourceApi response: {response}"; + _severity = Severity.Success; + } + catch (Exception ex) + { + _result = $"ResourceApi error: {ex.Message}"; + _severity = Severity.Error; + } + Snackbar.Add(_result, _severity); + } +} + diff --git a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs similarity index 88% rename from samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs rename to samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs index 42c4331e..2fdd35bf 100644 --- a/samples/blazor-server/UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; -namespace UltimateAuth.Sample.BlazorServer.Components.Pages +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages { public partial class Home { @@ -20,6 +20,16 @@ protected override async Task OnInitializedAsync() _authState = await AuthStateProvider.GetAuthenticationStateAsync(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await StateManager.EnsureAsync(); + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + StateHasChanged(); + } + } + private void OnDiagnosticsChanged() { InvokeAsync(StateHasChanged); diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor similarity index 100% rename from samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor rename to samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor 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..7fa3f557 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -0,0 +1,41 @@ +using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm; +using Microsoft.AspNetCore.Components.Authorization; +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.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +builder.Services.AddUltimateAuth(); +builder.Services.AddUltimateAuthClient(o => +{ + o.Endpoints.Authority = "https://localhost:6110"; +}); + +//builder.Services.AddScoped(); +//builder.Services.AddScoped(); + +builder.Services.AddAuthorizationCore(); + +builder.Services.AddMudServices(); +builder.Services.AddMudExtensions(); + +builder.Services.AddHttpClient("UAuthHub", client => +{ + client.BaseAddress = new Uri("https://localhost:6110"); +}); + +builder.Services.AddHttpClient("ResourceApi", client => +{ + client.BaseAddress = new Uri("https://localhost:6120"); +}); + +await builder.Build().RunAsync(); diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json similarity index 85% rename from samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json rename to samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json index ce3dc89b..0e706d48 100644 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5008", + "applicationUrl": "http://localhost:6131", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -16,7 +16,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7203;http://localhost:5008", + "applicationUrl": "https://localhost:6130;http://localhost:6131", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor similarity index 72% rename from samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor rename to samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor index 34c73543..cdb34a97 100644 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor @@ -2,9 +2,16 @@ @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing +@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 +@using CodeBeam.UltimateAuth.Client + +@using MudBlazor +@using MudExtensions diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css similarity index 100% rename from samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css rename to samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ UltimateAuth.Sample.BlazorStandaloneWasm - + + + + @@ -27,6 +30,9 @@ 🗙 + + + diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json similarity index 100% rename from samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json rename to samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/App.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/App.razor deleted file mode 100644 index 6fd3ed1b..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/App.razor +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor deleted file mode 100644 index 76eb7252..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor +++ /dev/null @@ -1,16 +0,0 @@ -@inherits LayoutComponentBase -
- - -
- - -
- @Body -
-
-
diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor deleted file mode 100644 index 109081ef..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor +++ /dev/null @@ -1,39 +0,0 @@ - - - - -@code { - private bool collapseNavMenu = true; - - private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; - - private void ToggleNavMenu() - { - collapseNavMenu = !collapseNavMenu; - } -} diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor.css deleted file mode 100644 index 617b89cc..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Layout/NavMenu.razor.css +++ /dev/null @@ -1,83 +0,0 @@ -.navbar-toggler { - background-color: rgba(255, 255, 255, 0.1); -} - -.top-row { - min-height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.bi { - display: inline-block; - position: relative; - width: 1.25rem; - height: 1.25rem; - margin-right: 0.75rem; - top: -1px; - background-size: cover; -} - -.bi-house-door-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); -} - -.bi-plus-square-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); -} - -.bi-list-nested-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - -.nav-item ::deep a.active { - background-color: rgba(255,255,255,0.37); - color: white; -} - -.nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .collapse { - /* Never collapse the sidebar for wide screens */ - display: block; - } - - .nav-scrollable { - /* Allow sidebar to scroll for tall menus */ - height: calc(100vh - 3.5rem); - overflow-y: auto; - } -} diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor deleted file mode 100644 index 075f3e4a..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ /dev/null @@ -1,12 +0,0 @@ -@page "/" -@using CodeBeam.UltimateAuth.Core.Runtime -@inject IUAuthProductInfoProvider ProductInfo - -Home - -

Hello, world!

- -Welcome to your new app. - -@ProductInfo.Get().ProductName v @ProductInfo.Get().Version -Client Profile: @ProductInfo.Get().ClientProfile.ToString() diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs deleted file mode 100644 index c1d8465b..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Extensions; -using CodeBeam.UltimateAuth.Core.Extensions; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using UltimateAuth.Sample.BlazorStandaloneWasm; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); -builder.RootComponents.Add("head::after"); - -builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); - -builder.Services.AddUltimateAuth(); -builder.Services.AddUltimateAuthClient(); - -await builder.Build().RunAsync(); diff --git a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css deleted file mode 100644 index 3882a819..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css +++ /dev/null @@ -1,4085 +0,0 @@ -/*! - * 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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map deleted file mode 100644 index ce99ec19..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map +++ /dev/null @@ -1 +0,0 @@ -{"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":"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EE,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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nEV;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;IAO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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;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AAA;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/l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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;I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","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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css deleted file mode 100644 index 49b843b1..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * 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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map deleted file mode 100644 index a0db8b57..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map +++ /dev/null @@ -1 +0,0 @@ -{"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":"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","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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css deleted file mode 100644 index 1a5d6563..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css +++ /dev/null @@ -1,4084 +0,0 @@ -/*! - * 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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map deleted file mode 100644 index 8df43cfc..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map +++ /dev/null @@ -1 +0,0 @@ -{"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":"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EE,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;EA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;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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nEV;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;IAO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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;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AAA;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/l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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;I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","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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css deleted file mode 100644 index 672cbc2e..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * 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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map deleted file mode 100644 index 1c926af5..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map +++ /dev/null @@ -1 +0,0 @@ -{"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":"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","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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css deleted file mode 100644 index 63054109..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css +++ /dev/null @@ -1,597 +0,0 @@ -/*! - * 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/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map b/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map deleted file mode 100644 index 5fe522b6..00000000 --- a/samples/blazor-standalone-wasm/UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map +++ /dev/null @@ -1 +0,0 @@ -{"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":"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","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-`

/// /// - Task BackgroundPostAsync(string endpoint); + Task FetchPostAsync(string endpoint); - Task> BackgroundPostJsonAsync(string url); + Task> FetchPostJsonAsync(string url); } } 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/Authentication/DefaultUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs new file mode 100644 index 00000000..8a1a767b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs @@ -0,0 +1,69 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Client.Authentication +{ + internal sealed class DefaultUAuthStateManager : IUAuthStateManager + { + private readonly IUAuthClient _client; + private readonly IClock _clock; + + public UAuthState State { get; } = UAuthState.Anonymous(); + + public DefaultUAuthStateManager(IUAuthClient client, IClock clock) + { + _client = client; + _clock = clock; + } + + public async Task EnsureAsync(CancellationToken ct = default) + { + //if (!State.IsAuthenticated) + // return; + + //if (!State.IsStale) + // return; + + if (State.IsAuthenticated && !State.IsStale) + return; + + var result = await _client.ValidateAsync(); + + if (!result.IsValid) + { + State.Clear(); + return; + } + + State.ApplySnapshot(result.Snapshot, _clock.UtcNow); + } + + public async Task OnLoginAsync(CancellationToken ct = default) + { + var result = await _client.ValidateAsync(); + + if (!result.IsValid || result.Snapshot is null) + { + State.Clear(); + return; + } + + var now = _clock.UtcNow; + + State.ApplySnapshot( + result.Snapshot, + validatedAt: now); + } + + + public Task OnLogoutAsync() + { + State.Clear(); + return Task.CompletedTask; + } + + public void MarkStale() + { + State.MarkStale(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs new file mode 100644 index 00000000..efe49e79 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs @@ -0,0 +1,36 @@ +namespace CodeBeam.UltimateAuth.Client.Authentication +{ + /// + /// 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(CancellationToken ct = default); + + /// + /// Called after a successful login. + /// + Task OnLoginAsync(CancellationToken ct = default); + + /// + /// Called after logout. + /// + Task OnLogoutAsync(); + + /// + /// Forces state to be cleared and re-validation required. + /// + void MarkStale(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs new file mode 100644 index 00000000..d41b1f3d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using Microsoft.AspNetCore.Components.Authorization; +using System.Security.Principal; + +namespace CodeBeam.UltimateAuth.Client.Authentication +{ + internal sealed class UAuthAuthenticationStateProvider : AuthenticationStateProvider + { + private readonly IUAuthStateManager _stateManager; + + public UAuthAuthenticationStateProvider(IUAuthStateManager stateManager) + { + _stateManager = stateManager; + _stateManager.State.Changed += _ => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + //public override Task GetAuthenticationStateAsync() + //{ + // _stateManager.EnsureAsync(); + // var principal = _stateManager.State.ToClaimsPrincipal(); + // return Task.FromResult(new AuthenticationState(principal)); + //} + + public override Task GetAuthenticationStateAsync() + { + var principal = _stateManager.State.ToClaimsPrincipal(); + return Task.FromResult(new AuthenticationState(principal)); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs new file mode 100644 index 00000000..ffbe01c4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs @@ -0,0 +1,113 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Client.Authentication +{ + /// + /// 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 bool IsAuthenticated { get; private set; } + + public UserId? UserId { get; private set; } + + public string? TenantId { get; private set; } + + /// + /// When this authentication snapshot was created. + /// + public DateTimeOffset? AuthenticatedAt { get; private set; } + + /// + /// When this snapshot was last validated or refreshed. + /// + 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 ClaimsSnapshot Claims { get; private set; } = ClaimsSnapshot.Empty; + + public event Action? Changed; + + public static UAuthState Anonymous() => new(); + + internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt) + { + UserId = snapshot.UserId; + TenantId = snapshot.TenantId; + Claims = snapshot.Claims; + + IsAuthenticated = true; + + AuthenticatedAt = snapshot.AuthenticatedAt; + LastValidatedAt = validatedAt; + IsStale = false; + + Changed?.Invoke(UAuthStateChangeReason.Authenticated); + } + + + 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); + } + + internal void Clear() + { + Claims = ClaimsSnapshot.Empty; + + UserId = null; + TenantId = null; + IsAuthenticated = false; + + AuthenticatedAt = null; + LastValidatedAt = null; + IsStale = false; + + Changed?.Invoke(UAuthStateChangeReason.Cleared); + } + + /// + /// Creates a ClaimsPrincipal view for ASP.NET / Blazor integration. + /// + public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = "UltimateAuth") + { + if (!IsAuthenticated) + return new ClaimsPrincipal(new ClaimsIdentity()); + + var identity = new ClaimsIdentity( + Claims.AsDictionary() + .Select(kv => new Claim(kv.Key, kv.Value)), + authenticationType); + + return new ClaimsPrincipal(identity); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs new file mode 100644 index 00000000..b2b72dde --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Client.Authentication +{ + public enum UAuthStateChangeReason + { + Authenticated, + Validated, + MarkedStale, + Cleared + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index 8b1a4c65..ecff96b3 100644 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -10,16 +10,19 @@ - - + + + - - + + + + diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor index 7619c047..0ab9dbaa 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor @@ -1,10 +1,17 @@ @* TODO: Optional double-submit prevention for native form submit *@ @namespace CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Options +@using CodeBeam.UltimateAuth.Core.Options +@using Microsoft.Extensions.Options @inject IJSRuntime JS +@inject IOptions CoreOptions +@inject IOptions Options +@inject NavigationManager Navigation
+ @ChildContent @@ -13,7 +20,3 @@ }
- -@code { - -} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs index 07bc2a06..acaa36e5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Components; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; namespace CodeBeam.UltimateAuth.Client @@ -14,6 +16,9 @@ public partial class UALoginForm [Parameter] public string? Endpoint { get; set; } = "/auth/login"; + [Parameter] + public string? ReturnUrl { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } @@ -22,8 +27,6 @@ public partial class UALoginForm private ElementReference _form; - private string ResolvedEndpoint => string.IsNullOrWhiteSpace(Endpoint) ? "/auth/login" : Endpoint; - public async Task SubmitAsync() { if (_form.Context is null) @@ -31,5 +34,34 @@ public async Task SubmitAsync() await JS.InvokeVoidAsync("uauth.submitForm", _form); } + + private string ClientProfileValue => CoreOptions.Value.ClientProfile.ToString(); + + private string ResolvedEndpoint + { + get + { + var loginPath = string.IsNullOrWhiteSpace(Endpoint) + ? Options.Value.Endpoints.Login + : Endpoint; + + var baseUrl = UAuthUrlBuilder.Combine( + Options.Value.Endpoints.Authority, + loginPath); + + var returnUrl = EffectiveReturnUrl; + + if (string.IsNullOrWhiteSpace(returnUrl)) + return baseUrl; + + return $"{baseUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}"; + } + } + + private string EffectiveReturnUrl => + !string.IsNullOrWhiteSpace(ReturnUrl) + ? ReturnUrl + : Navigation.Uri; + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs index 67c0e413..0db7156b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs @@ -1,10 +1,14 @@ using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Authentication; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Client.Extensions @@ -69,15 +73,18 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol // services.AddSingleton, ...>(); services.AddSingleton(); - services.PostConfigure(o => - { - if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) - return; + services.AddSingleton, UAuthOptionsPostConfigure>(); + services.TryAddSingleton(); - using var sp = services.BuildServiceProvider(); - var detector = sp.GetRequiredService(); - o.ClientProfile = detector.Detect(sp); - }); + //services.PostConfigure(o => + //{ + // if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) + // return; + + // using var sp = services.BuildServiceProvider(); + // var detector = sp.GetRequiredService(); + // o.ClientProfile = detector.Detect(sp); + //}); services.PostConfigure(o => { @@ -89,9 +96,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddScoped(sp => { - var core = sp - .GetRequiredService>() - .Value; + var core = sp.GetRequiredService>().Value; return core.ClientProfile == UAuthClientProfile.BlazorServer ? sp.GetRequiredService() @@ -101,6 +106,8 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs index ad0cb789..4bc6e0de 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs @@ -1,5 +1,8 @@ using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; using Microsoft.JSInterop; namespace CodeBeam.UltimateAuth.Client.Infrastructure @@ -7,26 +10,48 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure internal sealed class BrowserPostClient : IBrowserPostClient { private readonly IJSRuntime _js; + private UAuthOptions _coreOptions; - public BrowserPostClient(IJSRuntime js) + public BrowserPostClient(IJSRuntime js, IOptions coreOptions) { _js = js; + _coreOptions = coreOptions.Value; } public Task NavigatePostAsync(string endpoint, IDictionary? data = null) { - return _js.InvokeVoidAsync("uauth.post", endpoint, data).AsTask(); + return _js.InvokeVoidAsync("uauth.post", new + { + url = endpoint, + mode = "navigate", + data = data, + clientProfile = _coreOptions.ClientProfile.ToString() + }).AsTask(); } - public async Task BackgroundPostAsync(string endpoint) + public async Task FetchPostAsync(string endpoint) { - var result = await _js.InvokeAsync("uauth.refresh", endpoint); + var result = await _js.InvokeAsync("uauth.post", new + { + url = endpoint, + mode = "fetch", + expectJson = false, + clientProfile = _coreOptions.ClientProfile.ToString() + }); + return result; } - public async Task> BackgroundPostJsonAsync(string url) + public async Task> FetchPostJsonAsync(string endpoint) { - var result = await _js.InvokeAsync>("uauth.validate", url); + var result = await _js.InvokeAsync>("uauth.post", new + { + url = endpoint, + mode = "fetch", + expectJson = true, + clientProfile = _coreOptions.ClientProfile.ToString() + }); + return result; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs new file mode 100644 index 00000000..38d5a75a --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs new file mode 100644 index 00000000..6f88564f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Client.Infrastructure +{ + internal static class UAuthUrlBuilder + { + public static string Combine(string authority, string relative) + { + return authority.TrimEnd('/') + "/" + relative.TrimStart('/'); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index 10aa8d00..0e6ffd10 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -12,6 +12,11 @@ public sealed class UAuthClientOptions public sealed class AuthEndpointOptions { + /// + /// Base URL of UAuthHub (e.g. https://localhost:6110) + /// + public string Authority { get; set; } = string.Empty; + public string Login { get; set; } = "/auth/login"; public string Logout { get; set; } = "/auth/logout"; public string Refresh { get; set; } = "/auth/refresh"; diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs index 0d8ed150..0101507e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs @@ -13,13 +13,15 @@ public UAuthClientProfile Detect(IServiceProvider sp) if (AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == "Microsoft.AspNetCore.Components.WebAssembly")) return UAuthClientProfile.BlazorWasm; - //if (sp.GetService() is not null) - // return UAuthClientProfile.BlazorServer; + // 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; + } - //if (sp.GetService() is not null) - // return UAuthClientProfile.Mvc; - - return UAuthClientProfile.NotSpecified; + // 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/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs new file mode 100644 index 00000000..b99dccde --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure +{ + internal sealed class UAuthOptionsPostConfigure : IPostConfigureOptions + { + private readonly IClientProfileDetector _detector; + private readonly IServiceProvider _services; + + public UAuthOptionsPostConfigure(IClientProfileDetector detector, IServiceProvider services) + { + _detector = detector; + _services = services; + } + + public void PostConfigure(string? name, UAuthOptions options) + { + if (!options.AutoDetectClientProfile) + return; + + if (options.ClientProfile != UAuthClientProfile.NotSpecified) + return; + + options.ClientProfile = _detector.Detect(_services); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs index 7709c2df..3c3416ac 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Core.Contracts; +using System.Security.Claims; namespace CodeBeam.UltimateAuth.Client { @@ -12,5 +13,4 @@ public interface IUAuthClient Task ValidateAsync(); } - } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index c0f75e82..086c3b89 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Authentication; using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Extensions; @@ -7,6 +8,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.Extensions.Options; +using System.Security.Claims; namespace CodeBeam.UltimateAuth.Client { @@ -28,12 +30,14 @@ public UAuthClient( public async Task LoginAsync(LoginRequest request) { - await _post.NavigatePostAsync(_options.Endpoints.Login, request.ToDictionary()); + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Login); + await _post.NavigatePostAsync(url, request.ToDictionary()); } public async Task LogoutAsync() { - await _post.NavigatePostAsync(_options.Endpoints.Logout); + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Logout); + await _post.NavigatePostAsync(url); } public async Task RefreshAsync(bool isAuto = false) @@ -43,7 +47,8 @@ public async Task RefreshAsync(bool isAuto = false) _diagnostics.MarkManualRefresh(); } - var result = await _post.BackgroundPostAsync(_options.Endpoints.Refresh); + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Refresh); + var result = await _post.FetchPostAsync(url); var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); switch (refreshOutcome) { @@ -69,12 +74,16 @@ public async Task RefreshAsync(bool isAuto = false) }; } - public Task ReauthAsync() - => _post.NavigatePostAsync(_options.Endpoints.Reauth); + public async Task ReauthAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Reauth); + await _post.NavigatePostAsync(_options.Endpoints.Reauth); + } public async Task ValidateAsync() { - var result = await _post.BackgroundPostJsonAsync(_options.Endpoints.Validate); + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate); + var result = await _post.FetchPostJsonAsync(url); if (result.Body is null) return new AuthValidationResult { IsValid = false, State = "transport" }; @@ -82,7 +91,9 @@ public async Task ValidateAsync() return new AuthValidationResult { IsValid = result.Body.IsValid, - State = result.Body.State + State = result.Body.State, + RemainingAttempts = result.Body.RemainingAttempts, + Snapshot = result.Body.Snapshot, }; } diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js index daf87d1a..07e521b1 100644 --- a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js +++ b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js @@ -7,50 +7,58 @@ }; window.uauth = { - post: function (action, data) { - const form = document.createElement("form"); - form.method = "POST"; - form.action = action; - - 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); - } - } + post: async function (options) { + const { + url, + mode, + data, + expectJson, + clientProfile + } = options; - document.body.appendChild(form); - form.submit(); - }, + if (mode === "navigate") { + const form = document.createElement("form"); + form.method = "POST"; + form.action = url; - refresh: async function (action) { - const response = await fetch(action, { - method: "POST", - credentials: "include" - }); + const cp = document.createElement("input"); + cp.type = "hidden"; + cp.name = "__uauth_client_profile"; + cp.value = clientProfile ?? ""; + form.appendChild(cp); - return { - ok: response.ok, - status: response.status, - refreshOutcome: response.headers.get("X-UAuth-Refresh") - }; - }, + 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); + } + } - validate: async function (action) { - const response = await fetch(action, { + document.body.appendChild(form); + form.submit(); + return null; + } + + const response = await fetch(url, { method: "POST", - credentials: "include" + credentials: "include", + headers: { + "X-UAuth-ClientProfile": clientProfile + } }); let body = null; - try { body = await response.json(); } catch { } + if (expectJson) { + try { body = await response.json(); } catch { } + } return { ok: response.ok, status: response.status, + refreshOutcome: response.headers.get("X-UAuth-Refresh"), body: body }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs deleted file mode 100644 index 00523451..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface IRefreshTokenResolver - { - Task?> ResolveAsync(string? tenantId, string refreshToken, DateTimeOffset now, CancellationToken ct = default); - } - -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs index c78e06af..269cb47b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs @@ -7,9 +7,9 @@ /// public interface IUAuthService { - IUAuthFlowService Flow { get; } + //IUAuthFlowService Flow { get; } IUAuthSessionService Sessions { get; } - IUAuthTokenService Tokens { get; } + //IUAuthTokenService Tokens { get; } IUAuthUserService Users { get; } } } 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..6e52d309 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs @@ -0,0 +1,26 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Optional persistence for access token identifiers (jti). + /// Used for revocation and replay protection. + /// + public interface IAccessTokenIdStore + { + Task StoreAsync( + string? tenantId, + string jti, + DateTimeOffset expiresAt, + CancellationToken ct = default); + + Task IsRevokedAsync( + string? tenantId, + string jti, + CancellationToken ct = default); + + Task RevokeAsync( + string? tenantId, + string jti, + DateTimeOffset revokedAt, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs deleted file mode 100644 index 2f5e141f..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface IOpaqueTokenStore - { - Task FindByHashAsync( - string tokenHash, - 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..cb360592 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Low-level persistence abstraction for refresh tokens. +/// NO validation logic. NO business rules. +/// +public interface IRefreshTokenStore +{ + Task StoreAsync(string? tenantId, + StoredRefreshToken token, + CancellationToken ct = default); + + Task?> FindByHashAsync(string? tenantId, + string tokenHash, + CancellationToken ct = default); + + Task RevokeAsync(string? tenantId, + string tokenHash, + DateTimeOffset revokedAt, + CancellationToken ct = default); + + Task RevokeBySessionAsync(string? tenantId, + AuthSessionId sessionId, + DateTimeOffset revokedAt, + CancellationToken ct = default); + + Task RevokeByChainAsync(string? tenantId, + ChainId chainId, + DateTimeOffset revokedAt, + CancellationToken ct = default); + + Task RevokeAllForUserAsync(string? tenantId, + TUserId userId, + DateTimeOffset revokedAt, + CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs deleted file mode 100644 index d414189c..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs +++ /dev/null @@ -1,76 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Provides persistence and validation support for issued tokens, - /// including refresh tokens and optional access token identifiers (jti). - /// - public interface ITokenStore - { - /// - /// Persists a refresh token hash associated with a session. - /// - Task StoreRefreshTokenAsync( - string? tenantId, - TUserId userId, - AuthSessionId sessionId, - string refreshTokenHash, - DateTimeOffset expiresAt); - - /// - /// Validates a provided refresh token against the stored hash. - /// Returns true if valid and not expired or revoked. - /// - Task> ValidateRefreshTokenAsync( - string? tenantId, - string providedRefreshToken, - DateTimeOffset now); - - - /// - /// Revokes the refresh token associated with the specified session. - /// - Task RevokeRefreshTokenAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset at); - - /// - /// Revokes all refresh tokens belonging to the user. - /// - Task RevokeAllRefreshTokensAsync( - string? tenantId, - TUserId userId, - DateTimeOffset at); - - // ------------------------------------------------------------ - // ACCESS TOKEN IDENTIFIERS (OPTIONAL) - // ------------------------------------------------------------ - - /// - /// Stores a JWT ID (jti) for replay detection or revocation. - /// Implementations may ignore this if not supported. - /// - Task StoreTokenIdAsync( - string? tenantId, - string jti, - DateTimeOffset expiresAt); - - /// - /// Determines whether the specified token identifier has been revoked. - /// - Task IsTokenIdRevokedAsync( - string? tenantId, - string jti); - - /// - /// Revokes a token identifier, preventing further usage. - /// - Task RevokeTokenIdAsync( - string? tenantId, - string jti, - DateTimeOffset at); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs deleted file mode 100644 index 4e3fdefe..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface ITokenStoreFactory - { - ITokenStoreKernel Create(string? tenantId); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs deleted file mode 100644 index d708c842..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs +++ /dev/null @@ -1,47 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Low-level persistence abstraction for token-related data. - /// Handles refresh tokens and optional access token identifiers (jti). - /// - public interface ITokenStoreKernel - { - Task SaveRefreshTokenAsync( - string? tenantId, - StoredRefreshToken token); - - Task GetRefreshTokenAsync( - string? tenantId, - string tokenHash); - - Task RevokeRefreshTokenAsync( - string? tenantId, - string tokenHash, - DateTimeOffset at); - - Task RevokeAllRefreshTokensAsync( - string? tenantId, - string? userId, - DateTimeOffset at); - - Task DeleteExpiredRefreshTokensAsync( - string? tenantId, - DateTimeOffset now); - - Task StoreTokenIdAsync( - string? tenantId, - string jti, - DateTimeOffset expiresAt); - - Task IsTokenIdRevokedAsync( - string? tenantId, - string jti); - - Task RevokeTokenIdAsync( - string? tenantId, - string jti, - DateTimeOffset at); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs similarity index 69% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs index d2010fb8..f342e7c6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs @@ -6,11 +6,8 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// Validates access tokens (JWT or opaque) and resolves /// the authenticated user context. /// - public interface ITokenValidator + public interface IJwtValidator { - Task> ValidateAsync( - string token, - TokenType type, - CancellationToken ct = default); + 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..78c6c8ef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IRefreshTokenValidator +{ + Task> ValidateAsync( + string? tenantId, + string refreshToken, + DateTimeOffset now, + CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index 07826e24..50348cae 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore")] diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index 98f38954..69a742ac 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -15,7 +15,7 @@ public sealed record LoginRequest /// Hint to request access/refresh tokens when the server mode supports it. /// Server policy may still ignore this. /// - public bool RequestTokens { get; init; } + public bool RequestTokens { get; init; } = true; // Optional public ChainId? ChainId { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs new file mode 100644 index 00000000..f0045753 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record AuthStateSnapshot + { + public string? UserId { get; init; } + public string? TenantId { get; init; } + + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; + + public DateTimeOffset? AuthenticatedAt { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs index b0af06c4..1cf3e36b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs @@ -4,11 +4,23 @@ public sealed record AuthValidationResult { public bool IsValid { get; init; } public string? State { get; init; } - public int? RemainingAttempts { get; init; } - public static AuthValidationResult Valid() => new() { IsValid = true, State = "active" }; + public AuthStateSnapshot? Snapshot { get; init; } + + public static AuthValidationResult Valid(AuthStateSnapshot? snapshot = null) + => new() + { + IsValid = true, + State = "active", + Snapshot = snapshot + }; - public static AuthValidationResult Invalid(string state) => new() { IsValid = false, State = state }; + public static AuthValidationResult Invalid(string state) + => new() + { + IsValid = false, + State = state + }; } } 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..d4453a8a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshTokenRotationContext +{ + public string RefreshToken { get; init; } = default!; + public DateTimeOffset Now { 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..b0efbe26 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshTokenRotationResult +{ + public bool IsSuccess { get; init; } + public bool ReauthRequired { get; init; } + + public AccessToken? AccessToken { get; init; } + public RefreshToken? RefreshToken { get; init; } + + private RefreshTokenRotationResult() { } + + public static RefreshTokenRotationResult Failed() => new() { IsSuccess = false, ReauthRequired = true }; + + public static RefreshTokenRotationResult Success( + AccessToken accessToken, + RefreshToken refreshToken) + => new() + { + IsSuccess = true, + AccessToken = accessToken, + RefreshToken = refreshToken + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs new file mode 100644 index 00000000..93f5a44a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record RefreshTokenValidationContext + { + public string TenantId { get; init; } = default!; + public AuthSessionId SessionId { get; init; } + public string ProvidedRefreshToken { get; init; } = default!; + public DateTimeOffset Now { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs index cabbb2a8..3c0699ff 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs @@ -7,11 +7,17 @@ public sealed record RefreshTokenValidationResult public bool IsValid { get; init; } public bool IsReuseDetected { get; init; } + public string? TenantId { get; init; } public TUserId? UserId { get; init; } public AuthSessionId? SessionId { get; init; } + public ChainId? ChainId { get; init; } + + public DateTimeOffset? ExpiresAt { get; init; } + + private RefreshTokenValidationResult() { } // ---------------------------- @@ -25,22 +31,34 @@ public static RefreshTokenValidationResult Invalid() IsReuseDetected = false }; - public static RefreshTokenValidationResult ReuseDetected() - => new() - { - IsValid = false, - IsReuseDetected = true - }; + public static RefreshTokenValidationResult ReuseDetected( + string? tenantId = null, + AuthSessionId? sessionId = null, + ChainId? chainId = null, + TUserId? userId = default) + => new() + { + IsValid = false, + IsReuseDetected = true, + TenantId = tenantId, + SessionId = sessionId, + ChainId = chainId, + UserId = userId + }; public static RefreshTokenValidationResult Valid( - TUserId userId, - AuthSessionId sessionId) - => new() - { - IsValid = true, - IsReuseDetected = false, - UserId = userId, - SessionId = sessionId - }; + string? tenantId, + TUserId userId, + AuthSessionId sessionId, + ChainId? chainId = null) + => new() + { + IsValid = true, + IsReuseDetected = false, + TenantId = tenantId, + UserId = userId, + SessionId = sessionId, + ChainId = chainId + }; } } 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..b36c1df6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + // It's not primary token kind, it's about transport format. + public enum TokenFormat + { + Opaque = 1, + Jwt = 2 + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs similarity index 79% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs index 9f9419a0..fde5c8ac 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed record TokenIssuanceContext { 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..62b8fd10 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + // This is for AuthFlowContext, with minimal data and no db access + 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/Domain/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs new file mode 100644 index 00000000..ed0ca749 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public enum AuthFlowType + { + Login, + Reauthentication, + + Logout, + RefreshSession, + ValidateSession, + + IssueToken, + RefreshToken, + IntrospectToken, + RevokeToken, + + QuerySession, + RevokeSession, + + UserInfo, + PermissionQuery, + + ApiAccess + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs new file mode 100644 index 00000000..0bae1432 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public enum CredentialKind + { + Session, + AccessToken, + RefreshToken + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index fc73a9a8..21904618 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -1,20 +1,24 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +using System.Security.Claims; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain { public sealed class ClaimsSnapshot { - private readonly IReadOnlyDictionary _claims; + public IReadOnlyDictionary Claims { get; } + [JsonConstructor] public ClaimsSnapshot(IReadOnlyDictionary claims) { - _claims = new Dictionary(claims); + Claims = new Dictionary(claims); } - public IReadOnlyDictionary AsDictionary() => _claims; + public IReadOnlyDictionary AsDictionary() => Claims; - public bool TryGet(string type, out string value) => _claims.TryGetValue(type, out value); + public bool TryGet(string type, out string value) => Claims.TryGetValue(type, out value); public string? Get(string type) - => _claims.TryGetValue(type, out var value) + => Claims.TryGetValue(type, out var value) ? value : null; @@ -25,12 +29,12 @@ public override bool Equals(object? obj) if (obj is not ClaimsSnapshot other) return false; - if (_claims.Count != other._claims.Count) + if (Claims.Count != other.Claims.Count) return false; - foreach (var kv in _claims) + foreach (var kv in Claims) { - if (!other._claims.TryGetValue(kv.Key, out var v)) + if (!other.Claims.TryGetValue(kv.Key, out var v)) return false; if (!string.Equals(kv.Value, v, StringComparison.Ordinal)) @@ -45,7 +49,7 @@ public override int GetHashCode() unchecked { int hash = 17; - foreach (var kv in _claims.OrderBy(x => x.Key)) + foreach (var kv in Claims.OrderBy(x => x.Key)) { hash = hash * 23 + kv.Key.GetHashCode(); hash = hash * 23 + kv.Value.GetHashCode(); @@ -68,7 +72,7 @@ public ClaimsSnapshot With(params (string Type, string Value)[] claims) if (claims.Length == 0) return this; - var dict = new Dictionary(_claims, StringComparer.Ordinal); + var dict = new Dictionary(Claims, StringComparer.Ordinal); foreach (var (type, value) in claims) { @@ -80,15 +84,15 @@ public ClaimsSnapshot With(params (string Type, string Value)[] claims) public ClaimsSnapshot Merge(ClaimsSnapshot other) { - if (other is null || other._claims.Count == 0) + if (other is null || other.Claims.Count == 0) return this; - if (_claims.Count == 0) + if (Claims.Count == 0) return other; - var dict = new Dictionary(_claims, StringComparer.Ordinal); + var dict = new Dictionary(Claims, StringComparer.Ordinal); - foreach (var kv in other._claims) + foreach (var kv in other.Claims) { dict[kv.Key] = kv.Value; } @@ -96,7 +100,34 @@ public ClaimsSnapshot Merge(ClaimsSnapshot other) return new ClaimsSnapshot(dict); } - // TODO: Add ToClaimsPrincipal and FromClaimsPrincipal methods + public static ClaimsSnapshot FromClaimsPrincipal(ClaimsPrincipal principal) + { + if (principal is null) + return Empty; + + if (principal.Identity?.IsAuthenticated != true) + return Empty; + + var dict = new Dictionary(StringComparer.Ordinal); + + foreach (var claim in principal.Claims) + { + dict[claim.Type] = claim.Value; + } + + return new ClaimsSnapshot(dict); + } + + public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = "UltimateAuth") + { + if (Claims.Count == 0) + return new ClaimsPrincipal(new ClaimsIdentity()); + + var claims = Claims.Select(kv => new Claim(kv.Key, kv.Value)); + var identity = new ClaimsIdentity(claims, authenticationType); + + return new ClaimsPrincipal(identity); + } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs index 31885b4c..dec5c2f1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs @@ -5,6 +5,7 @@ public enum RefreshOutcome None, NoOp, Touched, + Rotated, ReauthRequired } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs index fd91893a..00197209 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs @@ -1,19 +1,33 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +using System.ComponentModel.DataAnnotations.Schema; + +namespace CodeBeam.UltimateAuth.Core.Domain { /// /// Represents a persisted refresh token bound to a session. /// Stored as a hashed value for security reasons. /// - public sealed record StoredRefreshToken + public sealed record StoredRefreshToken { public string TokenHash { get; init; } = default!; + public string? TenantId { get; init; } + + public TUserId UserId { get; init; } = default!; + public AuthSessionId SessionId { get; init; } = default!; + public ChainId? ChainId { get; init; } + public DateTimeOffset IssuedAt { get; init; } public DateTimeOffset ExpiresAt { get; init; } - public DateTimeOffset? RevokedAt { get; init; } + public string? ReplacedByTokenHash { get; init; } + + [NotMapped] public bool IsRevoked => RevokedAt.HasValue; + + public bool IsExpired(DateTimeOffset now) => ExpiresAt <= now; + + public bool IsActive(DateTimeOffset now) => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs new file mode 100644 index 00000000..2a8688d1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Extensions +{ + public static class ClaimsSnapshotExtensions + { + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = "UltimateAuth") + { + var claims = snapshot + .AsDictionary() + .Select(kv => new Claim(kv.Key, kv.Value)); + + var identity = new ClaimsIdentity(claims, authenticationType); + return new ClaimsPrincipal(identity); + } + + public static IReadOnlyCollection ToClaims(this ClaimsSnapshot snapshot) + { + if (snapshot == null) + return Array.Empty(); + + return snapshot + .AsDictionary() + .Select(kv => new Claim(kv.Key, kv.Value)) + .ToArray(); + } + + public static ClaimsSnapshot ToSnapshot(this IEnumerable claims) + { + if (claims == null) + return ClaimsSnapshot.Empty; + + return new ClaimsSnapshot( + claims + .GroupBy(c => c.Type) + .ToDictionary( + g => g.Key, + g => g.Last().Value, + StringComparer.Ordinal + ) + ); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs new file mode 100644 index 00000000..75d026c3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class DefaultRefreshTokenValidator : IRefreshTokenValidator +{ + private readonly IRefreshTokenStore _store; + private readonly ITokenHasher _hasher; + + public DefaultRefreshTokenValidator( + IRefreshTokenStore store, + ITokenHasher hasher) + { + _store = store; + _hasher = hasher; + } + + public async Task> ValidateAsync( + string? tenantId, + string refreshToken, + DateTimeOffset now, + CancellationToken ct = default) + { + var hash = _hasher.Hash(refreshToken); + + var stored = await _store.FindByHashAsync(tenantId, hash, ct); + + if (stored is null) + return RefreshTokenValidationResult.Invalid(); + + if (stored.IsRevoked) + return RefreshTokenValidationResult.ReuseDetected( + tenantId: stored.TenantId, + sessionId: stored.SessionId, + chainId: stored.ChainId, + userId: stored.UserId); + + if (stored.IsExpired(now)) + { + await _store.RevokeAsync(tenantId, hash, now, ct); + return RefreshTokenValidationResult.Invalid(); + } + + return RefreshTokenValidationResult.Valid( + tenantId: stored.TenantId, + stored.UserId, + stored.SessionId, + stored.ChainId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs new file mode 100644 index 00000000..ebb56c66 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + internal sealed class NoopAccessTokenIdStore : IAccessTokenIdStore + { + public Task StoreAsync(string? tenantId, string jti, DateTimeOffset expiresAt, CancellationToken ct = default) + => Task.CompletedTask; + + public Task IsRevokedAsync(string? tenantId, string jti, CancellationToken ct = default) + => Task.FromResult(false); + + public Task RevokeAsync(string? tenantId, string jti, DateTimeOffset revokedAt, CancellationToken ct = default) + => Task.CompletedTask; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs deleted file mode 100644 index 0be3625f..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs +++ /dev/null @@ -1,75 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Infrastructure -{ - public sealed class UAuthRefreshTokenResolver : IRefreshTokenResolver - { - private readonly ISessionStoreFactory _sessionStoreFactory; - private readonly ITokenStoreFactory _tokenStoreFactory; - private readonly ITokenHasher _hasher; - - public UAuthRefreshTokenResolver(ISessionStoreFactory sessionStoreFactory, ITokenStoreFactory tokenStoreFactory, ITokenHasher hasher) - { - _sessionStoreFactory = sessionStoreFactory; - _tokenStoreFactory = tokenStoreFactory; - _hasher = hasher; - } - - public async Task?> ResolveAsync(string? tenantId, string refreshToken, DateTimeOffset now, CancellationToken ct = default) - { - var tokenHash = _hasher.Hash(refreshToken); - - var tokenStore = _tokenStoreFactory.Create(tenantId); - var sessionStore = _sessionStoreFactory.Create(tenantId); - - var stored = await tokenStore.GetRefreshTokenAsync( - tenantId, - tokenHash); - - if (stored is null) - return null; - - if (stored.IsRevoked) - { - return ResolvedRefreshSession.Reused(); - } - - if (stored.ExpiresAt <= now) - { - await tokenStore.RevokeRefreshTokenAsync( - tenantId, - tokenHash, - now); - - return ResolvedRefreshSession.Invalid(); - } - - var session = await sessionStore.GetSessionAsync( - tenantId, - stored.SessionId); - - if (session is null) - return null; - - if (session.IsRevoked || session.ExpiresAt <= now) - return null; - - var chain = await sessionStore.GetChainAsync( - tenantId, - session.ChainId); - - if (chain is null || chain.IsRevoked) - return null; - - await tokenStore.RevokeRefreshTokenAsync( - tenantId, - tokenHash, - now); - - return ResolvedRefreshSession.Valid( - session, - chain); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/HeaderTokenFormat.cs b/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs similarity index 60% rename from src/CodeBeam.UltimateAuth.Server/Contracts/HeaderTokenFormat.cs rename to src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs index 5fa3c2f4..691dbd40 100644 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/HeaderTokenFormat.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Contracts +namespace CodeBeam.UltimateAuth.Core.Options { public enum HeaderTokenFormat { diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenResponseMode.cs b/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs similarity index 69% rename from src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenResponseMode.cs rename to src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs index af35d5dd..ce777e15 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenResponseMode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Options { public enum TokenResponseMode { diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs index ad76bd10..3f74c77b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs @@ -6,7 +6,7 @@ public enum UAuthClientProfile BlazorWasm, BlazorServer, Maui, - Mvc, + WebServer, Api } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs index 6774eb71..9c0fdec5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs @@ -66,5 +66,21 @@ public sealed class UAuthMultiTenantOptions // Header config public string HeaderName { get; set; } = "X-Tenant"; + + internal UAuthMultiTenantOptions Clone() => new() + { + Enabled = Enabled, + DefaultTenantId = DefaultTenantId, + RequireTenant = RequireTenant, + AllowUnknownTenants = AllowUnknownTenants, + ReservedTenantIds = new HashSet(ReservedTenantIds), + NormalizeToLowercase = NormalizeToLowercase, + TenantIdRegex = TenantIdRegex, + EnableRoute = EnableRoute, + EnableHeader = EnableHeader, + EnableDomain = EnableDomain, + HeaderName = HeaderName + }; + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs index 18af314d..593fed2b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Options +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Options { /// /// Configuration settings for PKCE (Proof Key for Code Exchange) @@ -13,5 +15,11 @@ public sealed class UAuthPkceOptions /// 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 index 027b7f62..2d323487 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -2,8 +2,7 @@ namespace CodeBeam.UltimateAuth.Core.Options { - // TODO: Add rotate on refresh (especially on pureopaque). Currently PureOpaque sessions do not rotate on refresh. - // It's not a security branch, but it would be nice to have for privacy reasons. + // 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. /// @@ -92,5 +91,22 @@ public sealed class UAuthSessionOptions 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 index 4525aa96..5ef2e046 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -61,5 +61,20 @@ public sealed class UAuthTokenOptions /// 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.Server/Abstractions/ICredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResolver.cs deleted file mode 100644 index 7f440403..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResolver.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Abstractions -{ - /// - /// Gets the credential from the HTTP context. - /// IPrimaryCredentialResolver is used to determine which kind of credential to resolve. - /// - public interface ICredentialResolver - { - ResolvedCredential? Resolve(HttpContext context); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index 5926ded8..c195989b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -1,10 +1,10 @@ -using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Abstractions { public interface ICredentialResponseWriter { - void Write(HttpContext context, string value, CredentialResponseOptions options); + void Write(HttpContext context, CredentialKind kind, string value); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs new file mode 100644 index 00000000..1a727852 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs @@ -0,0 +1,13 @@ +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/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs new file mode 100644 index 00000000..ff3b2813 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -0,0 +1,15 @@ +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, 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/DefaultAuthFlowContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs new file mode 100644 index 00000000..856ec260 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Server.Auth +{ + internal sealed class DefaultAuthFlowContextAccessor : IAuthFlowContextAccessor + { + private static readonly AsyncLocal _current = new(); + + public AuthFlowContext Current => _current.Value ?? throw new InvalidOperationException("AuthFlowContext is not available for this request."); + + internal void Set(AuthFlowContext context) + { + _current.Value = 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..0b4666fc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public interface IAuthFlowContextAccessor + { + AuthFlowContext Current { get; } + } + +} 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..ad1f31c9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -0,0 +1,64 @@ +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 +{ + public sealed class AuthFlowContext + { + + public AuthFlowType FlowType { get; } + public UAuthClientProfile ClientProfile { get; } + public UAuthMode EffectiveMode { get; } + + + public string? TenantId { get; } + public bool IsAuthenticated { get; } + public UserId? UserId { get; } + public AuthSessionId? SessionId { get; } + + + public UAuthServerOptions OriginalOptions { get; } + public EffectiveUAuthServerOptions EffectiveOptions { get; } + + + public EffectiveAuthResponse Response { get; } + public PrimaryTokenKind PrimaryTokenKind { get; } + + // Helpers + public bool AllowsTokenIssuance => + Response.AccessTokenDelivery.Mode != TokenResponseMode.None || + Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; + + internal AuthFlowContext( + AuthFlowType flowType, + UAuthClientProfile clientProfile, + UAuthMode effectiveMode, + string? tenantId, + bool isAuthenticated, + UserId? userId, + AuthSessionId? sessionId, + UAuthServerOptions originalOptions, + EffectiveUAuthServerOptions effectiveOptions, + EffectiveAuthResponse response, + PrimaryTokenKind primaryTokenKind) + { + FlowType = flowType; + ClientProfile = clientProfile; + EffectiveMode = effectiveMode; + + TenantId = tenantId; + IsAuthenticated = isAuthenticated; + UserId = userId; + SessionId = sessionId; + + OriginalOptions = originalOptions; + EffectiveOptions = effectiveOptions; + + Response = response; + PrimaryTokenKind = primaryTokenKind; + } + } +} 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..d064978e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -0,0 +1,66 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public interface IAuthFlowContextFactory + { + AuthFlowContext Create(HttpContext httpContext, AuthFlowType flowType); + } + + internal sealed class DefaultAuthFlowContextFactory : IAuthFlowContextFactory + { + private readonly IClientProfileReader _clientProfileReader; + private readonly IPrimaryTokenResolver _primaryTokenResolver; + private readonly IEffectiveServerOptionsProvider _serverOptionsProvider; + private readonly IAuthResponseResolver _authResponseResolver; + + public DefaultAuthFlowContextFactory( + IClientProfileReader clientProfileReader, + IPrimaryTokenResolver primaryTokenResolver, + IEffectiveServerOptionsProvider serverOptionsProvider, + IAuthResponseResolver authResponseResolver) + { + _clientProfileReader = clientProfileReader; + _primaryTokenResolver = primaryTokenResolver; + _serverOptionsProvider = serverOptionsProvider; + _authResponseResolver = authResponseResolver; + } + + public AuthFlowContext Create(HttpContext ctx, AuthFlowType flowType) + { + var tenant = ctx.GetTenantContext(); + var session = ctx.GetSessionContext(); + var user = ctx.GetUserContext(); + + var clientProfile = _clientProfileReader.Read(ctx); + var originalOptions = _serverOptionsProvider.GetOriginal(ctx); + var effectiveOptions = _serverOptionsProvider.GetEffective(ctx, flowType, clientProfile); + + var effectiveMode = effectiveOptions.Mode; + var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); + + var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions); + + // TODO: Implement invariant checker + //_invariantChecker.Validate(flowType, effectiveMode, response, effectiveOptions); + + return new AuthFlowContext( + flowType, + clientProfile, + effectiveMode, + tenant?.TenantId, + user?.IsAuthenticated ?? false, + user?.UserId, + session?.SessionId, + originalOptions, + effectiveOptions, + response, + primaryTokenKind + ); + } + + } +} 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..a3586c2a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs @@ -0,0 +1,27 @@ +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) + { + _authFlow.Begin(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..8c46b33f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowMetadata.cs @@ -0,0 +1,14 @@ +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/DefaultAuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs new file mode 100644 index 00000000..891abf0f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + internal sealed class DefaultAuthFlow : IAuthFlow + { + private readonly IHttpContextAccessor _http; + private readonly IAuthFlowContextFactory _factory; + private readonly DefaultAuthFlowContextAccessor _accessor; + + public DefaultAuthFlow( + IHttpContextAccessor http, + IAuthFlowContextFactory factory, + IAuthFlowContextAccessor accessor) + { + _http = http; + _factory = factory; + _accessor = (DefaultAuthFlowContextAccessor)accessor; + } + + public AuthFlowContext Begin(AuthFlowType flowType) + { + var ctx = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext."); + + var flowContext = _factory.Create(ctx, flowType); + _accessor.Set(flowContext); + + return flowContext; + } + + } +} 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..e4fbc7cb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public interface IAuthFlow + { + AuthFlowContext Begin(AuthFlowType flowType); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/DefaultClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultClientProfileReader.cs new file mode 100644 index 00000000..0ef907c7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultClientProfileReader.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + internal sealed class DefaultClientProfileReader : IClientProfileReader + { + private const string HeaderName = "X-UAuth-ClientProfile"; + private const string FormFieldName = "__uauth_client_profile"; + + public UAuthClientProfile Read(HttpContext context) + { + if (context.Request.Headers.TryGetValue(HeaderName, out var headerValue) && TryParse(headerValue, out var headerProfile)) + { + return headerProfile; + } + + if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(FormFieldName, 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/DefaultEffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultEffectiveServerOptionsProvider.cs new file mode 100644 index 00000000..c8188cf1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultEffectiveServerOptionsProvider.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + internal sealed class DefaultEffectiveServerOptionsProvider : IEffectiveServerOptionsProvider + { + private readonly IOptions _baseOptions; + private readonly IEffectiveAuthModeResolver _modeResolver; + + public DefaultEffectiveServerOptionsProvider(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 original = _baseOptions.Value; + var effectiveMode = _modeResolver.Resolve(original.Mode, clientProfile, flowType); + var options = original.Clone(); + options.Mode = effectiveMode; + + ConfigureDefaults.ApplyModeDefaults(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/DefaultPrimaryTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultPrimaryTokenResolver.cs new file mode 100644 index 00000000..8d8ff763 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultPrimaryTokenResolver.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + internal sealed class DefaultPrimaryTokenResolver : 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/EffectiveUAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs new file mode 100644 index 00000000..cf76608c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs @@ -0,0 +1,17 @@ +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 AuthResponseOptions 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..5b49da9b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public interface IClientProfileReader + { + UAuthClientProfile Read(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..248bfaf0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs @@ -0,0 +1,10 @@ +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/Response/AuthResponseOptionsModeTemplateResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs new file mode 100644 index 00000000..0fc05b22 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs @@ -0,0 +1,135 @@ +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 AuthResponseOptions 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 AuthResponseOptions PureOpaque(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = CredentialKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie, + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = CredentialKind.AccessToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = CredentialKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + Login = { RedirectEnabled = true }, + Logout = { RedirectEnabled = true } + }; + + private static AuthResponseOptions Hybrid(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = CredentialKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = CredentialKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = CredentialKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie + }, + Login = { RedirectEnabled = true }, + Logout = { RedirectEnabled = true } + }; + + private static AuthResponseOptions SemiHybrid(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = CredentialKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = CredentialKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = CredentialKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Header + }, + Login = { RedirectEnabled = true }, + Logout = { RedirectEnabled = true } + }; + + private static AuthResponseOptions PureJwt(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = CredentialKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = CredentialKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = CredentialKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Header + }, + Login = { RedirectEnabled = true }, + Logout = { RedirectEnabled = true } + }; + } +} 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..5f97c238 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -0,0 +1,55 @@ +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 AuthResponseOptions Adapt(AuthResponseOptions template, UAuthClientProfile clientProfile, UAuthMode effectiveMode, EffectiveUAuthServerOptions effectiveOptions) + { + return new AuthResponseOptions + { + SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, CredentialKind.Session, clientProfile), + AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, CredentialKind.AccessToken, clientProfile), + RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, CredentialKind.RefreshToken, clientProfile), + + Login = template.Login, + Logout = template.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, CredentialKind 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 + }; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultAuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultAuthResponseResolver.cs new file mode 100644 index 00000000..705099e0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultAuthResponseResolver.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 DefaultAuthResponseResolver : IAuthResponseResolver + { + private readonly AuthResponseOptionsModeTemplateResolver _template; + private readonly ClientProfileAuthResponseAdapter _adapter; + + public DefaultAuthResponseResolver(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); + + return new EffectiveAuthResponse( + bound.SessionIdDelivery, + bound.AccessTokenDelivery, + bound.RefreshTokenDelivery, + + new EffectiveLoginRedirectResponse( + bound.Login.RedirectEnabled, + bound.Login.SuccessRedirect, + bound.Login.FailureRedirect, + bound.Login.FailureQueryKey, + bound.Login.CodeQueryKey, + bound.Login.FailureCodes + ), + + new EffectiveLogoutRedirectResponse( + bound.Logout.RedirectEnabled, + bound.Logout.RedirectUrl, + bound.Logout.AllowReturnUrlOverride + ) + ); + } + + private static AuthResponseOptions BindCookies(AuthResponseOptions response, UAuthServerOptions server) + { + return new AuthResponseOptions + { + 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 + { + CredentialKind.Session => server.Cookie.Session, + CredentialKind.AccessToken => server.Cookie.AccessToken, + CredentialKind.RefreshToken => server.Cookie.RefreshToken, + _ => throw new InvalidOperationException($"Unsupported credential kind: {delivery.Kind}") + }; + + return delivery.WithCookie(cookie); + } + + private static void Validate(AuthResponseOptions 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."); + } + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultEffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultEffectiveAuthModeResolver.cs new file mode 100644 index 00000000..6c2ad30a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultEffectiveAuthModeResolver.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + internal sealed class DefaultEffectiveAuthModeResolver : IEffectiveAuthModeResolver + { + public UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType) + { + if (configuredMode.HasValue) + return configuredMode.Value; + + 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..24da3658 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public sealed record EffectiveAuthResponse( + CredentialResponseOptions SessionIdDelivery, + CredentialResponseOptions AccessTokenDelivery, + CredentialResponseOptions RefreshTokenDelivery, + EffectiveLoginRedirectResponse Login, + EffectiveLogoutRedirectResponse Logout + ); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs new file mode 100644 index 00000000..75d01ad4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public sealed record EffectiveLoginRedirectResponse + ( + bool RedirectEnabled, + string SuccessPath, + string FailurePath, + string FailureQueryKey, + string CodeQueryKey, + IReadOnlyDictionary FailureCodes + ); +} 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..34b4a672 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public sealed record EffectiveLogoutRedirectResponse + ( + bool RedirectEnabled, + string RedirectPath, + bool AllowReturnUrlOverride + ); +} 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..92f73fd6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs @@ -0,0 +1,11 @@ +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..6de3a100 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public interface IEffectiveAuthModeResolver + { + UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieOptions.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationCookieOptions.cs similarity index 70% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationCookieOptions.cs index fee6cc96..ae0e1b32 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationCookieOptions.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Server.Authentication; -public sealed class UAuthCookieOptions : AuthenticationSchemeOptions +public sealed class UAuthAuthenticationCookieOptions : AuthenticationSchemeOptions { // TODO: // - CookieName override diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs index b759701c..5f478587 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs @@ -6,7 +6,7 @@ public static class UAuthAuthenticationExtensions { public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder builder) { - return builder.AddScheme(UAuthCookieDefaults.AuthenticationScheme, + return builder.AddScheme(UAuthCookieDefaults.AuthenticationScheme, _ => { }); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs index b6f255bc..11c039b7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -1,8 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Runtime; -using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; @@ -11,40 +10,46 @@ namespace CodeBeam.UltimateAuth.Server.Authentication; -internal sealed class UAuthAuthenticationHandler - : AuthenticationHandler +internal sealed class UAuthAuthenticationHandler : AuthenticationHandler { - private readonly ICredentialResolver _credentialResolver; + private readonly ITransportCredentialResolver _transportCredentialResolver; + private readonly IFlowCredentialResolver _credentialResolver; private readonly ISessionQueryService _sessionQuery; + private readonly IAuthFlowContextFactory _flowFactory; + private readonly IAuthResponseResolver _responseResolver; + private readonly IClock _clock; public UAuthAuthenticationHandler( - IOptionsMonitor options, + ITransportCredentialResolver transportCredentialResolver, + IOptionsMonitor options, ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder, ISystemClock clock, - ICredentialResolver credentialResolver, + IFlowCredentialResolver credentialResolver, ISessionQueryService sessionQuery, + IAuthFlowContextFactory flowFactory, + IAuthResponseResolver responseResolver, IClock uauthClock) : base(options, logger, encoder, clock) { + _transportCredentialResolver = transportCredentialResolver; _credentialResolver = credentialResolver; _sessionQuery = sessionQuery; + _flowFactory = flowFactory; + _responseResolver = responseResolver; _clock = uauthClock; } protected override async Task HandleAuthenticateAsync() { - // 1️⃣ Credential al - var credential = _credentialResolver.Resolve(Context); + var credential = _transportCredentialResolver.Resolve(Context); if (credential is null) return AuthenticateResult.NoResult(); - // 2️⃣ SessionId parse et if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) - return AuthenticateResult.Fail("Invalid session id"); + return AuthenticateResult.Fail("Invalid credential"); - // 3️⃣ Session validate et var result = await _sessionQuery.ValidateSessionAsync( new SessionValidationContext { @@ -57,13 +62,8 @@ protected override async Task HandleAuthenticateAsync() if (!result.IsValid) return AuthenticateResult.NoResult(); - // 4️⃣ Claims üret var principal = CreatePrincipal(result); - - // 5️⃣ Ticket üret - var ticket = new AuthenticationTicket( - principal, - UAuthCookieDefaults.AuthenticationScheme); + var ticket = new AuthenticationTicket(principal,UAuthCookieDefaults.AuthenticationScheme); return AuthenticateResult.Success(ticket); } @@ -87,10 +87,7 @@ private static ClaimsPrincipal CreatePrincipal(SessionValidationResult r claims.Add(new Claim(key, value)); } - var identity = new ClaimsIdentity( - claims, - UAuthCookieDefaults.AuthenticationScheme); - + var identity = new ClaimsIdentity(claims, UAuthCookieDefaults.AuthenticationScheme); return new ClaimsPrincipal(identity); } diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs new file mode 100644 index 00000000..a37b36a2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Server.Contracts +{ + public enum RefreshTokenStatus + { + Valid = 0, + Expired = 1, + Revoked = 2, + NotFound = 3, + Reused = 4, + SessionMismatch = 5 + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookieManager.cs new file mode 100644 index 00000000..5aaa3785 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookieManager.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Cookies; + +internal sealed class DefaultUAuthCookieManager : 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/Cookies/DefaultUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs new file mode 100644 index 00000000..bd5f6da8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs @@ -0,0 +1,70 @@ +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Cookies; + +internal sealed class DefaultUAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder +{ + public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, TimeSpan? logicalLifetime) + { + 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 + }; + + options.SameSite = ResolveSameSite(src, context); + ApplyLifetime(options, src, logicalLifetime); + + 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, TimeSpan? logicalLifetime) + { + var buffer = src.Lifetime.IdleBuffer ?? TimeSpan.Zero; + TimeSpan? baseLifetime = null; + + // 1️⃣ Hard MaxAge override (base) + if (src.MaxAge is not null) + { + baseLifetime = src.MaxAge; + } + // 2️⃣ Absolute lifetime override (base) + else if (src.Lifetime.AbsoluteLifetimeOverride is not null) + { + baseLifetime = src.Lifetime.AbsoluteLifetimeOverride; + } + // 3️⃣ Logical lifetime (effective) + else if (logicalLifetime is not null) + { + baseLifetime = logicalLifetime; + } + + if (baseLifetime is not null) + { + target.MaxAge = baseLifetime.Value + buffer; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs index 3161cdb5..440f14a8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs @@ -4,9 +4,9 @@ namespace CodeBeam.UltimateAuth.Server.Cookies; public interface IUAuthCookieManager { - void Write(HttpContext context, string value, Action? configure = null); - - bool TryRead(HttpContext context, out string value); + void Write(HttpContext context, string name, string value, CookieOptions options); - void Delete(HttpContext context); + bool TryRead(HttpContext context, string name, out string value); + + void Delete(HttpContext context, string name); } diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs new file mode 100644 index 00000000..f80a2bca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/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.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Cookies; + +public interface IUAuthCookiePolicyBuilder +{ + CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, TimeSpan? logicalLifetime); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs deleted file mode 100644 index 69063f92..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthSessionCookieManager.cs +++ /dev/null @@ -1,91 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Cookies; - -internal sealed class UAuthSessionCookieManager : IUAuthCookieManager -{ - private readonly UAuthServerOptions _options; - - public UAuthSessionCookieManager(IOptions options) - { - _options = options.Value; - } - - public void Write(HttpContext context, string value, Action? configure = null) - { - var options = BuildCookieOptions(context); - configure?.Invoke(options); - - context.Response.Cookies.Append(_options.Cookie.Name, value, options); - } - - public bool TryRead(HttpContext context, out string value) - { - return context.Request.Cookies.TryGetValue(_options.Cookie.Name, out value!); - } - - public void Delete(HttpContext context) - { - context.Response.Cookies.Delete(_options.Cookie.Name); - } - - private CookieOptions BuildCookieOptions(HttpContext context) - { - var cookie = _options.Cookie; - var options = new CookieOptions - { - HttpOnly = cookie.HttpOnly, - Secure = cookie.SecurePolicy == CookieSecurePolicy.Always, - SameSite = ResolveSameSite(), - Path = cookie.Path ?? "/" - }; - - var maxAge = ResolveCookieMaxAge(); - if (maxAge is not null) - { - options.MaxAge = maxAge; - } - - return options; - } - - private SameSiteMode ResolveSameSite() - { - if (_options.Cookie.SameSiteOverride is not null) - return _options.Cookie.SameSiteOverride.Value; - - return _options.HubDeploymentMode switch - { - UAuthHubDeploymentMode.Embedded => SameSiteMode.Strict, - UAuthHubDeploymentMode.Integrated => SameSiteMode.Lax, - UAuthHubDeploymentMode.External => SameSiteMode.None, - _ => SameSiteMode.Lax - }; - } - - private TimeSpan? ResolveCookieMaxAge() - { - var cookie = _options.Cookie; - var session = _options.Session; - var tokens = _options.Tokens; - - if (cookie.MaxAge is not null) - return cookie.MaxAge; - - if (tokens.IssueRefresh) - { - return tokens.RefreshTokenLifetime; - } - - if (session.IdleTimeout is not null) - { - return session.IdleTimeout + cookie.IdleBuffer; - } - - return null; - } - - -} diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs index 26bdc224..b993991a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs +++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Diagnostics; -public sealed record UAuthDiagnostic(string Code, string Message, UAuthDiagnosticSeverity Severity); +public sealed record UAuthDiagnostic(string code, string message, UAuthDiagnosticSeverity severity); public enum UAuthDiagnosticSeverity { diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs index d9e1c6a9..c43e7083 100644 --- a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs +++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs @@ -5,15 +5,53 @@ namespace CodeBeam.UltimateAuth.Server.Diagnostics; internal static class UAuthStartupDiagnostics { - // TODO: Add startup log public static IEnumerable Analyze(UAuthServerOptions options) { - if (options.HubDeploymentMode == UAuthHubDeploymentMode.External && options.Cookie.SecurePolicy != CookieSecurePolicy.Always) + 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( - "UAUTH001", - "External UAuthHub without Secure cookies is unsafe. This should only be used for development or testing.", - UAuthDiagnosticSeverity.Warning); + 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/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index 1c248f5d..d44f90ad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -1,46 +1,56 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Contracts; using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; +namespace CodeBeam.UltimateAuth.Server.Endpoints; + public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler { + private readonly IAuthFlowContextAccessor _authContext; private readonly IUAuthFlowService _flow; private readonly IDeviceResolver _deviceResolver; - private readonly ITenantResolver _tenantResolver; private readonly IClock _clock; - private readonly IUAuthCookieManager _cookieManager; private readonly ICredentialResponseWriter _credentialResponseWriter; - private readonly UAuthServerOptions _options; + private readonly AuthRedirectResolver _redirectResolver; public DefaultLoginEndpointHandler( + IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IDeviceResolver deviceResolver, - ITenantResolver tenantResolver, IClock clock, - IUAuthCookieManager cookieManager, ICredentialResponseWriter credentialResponseWriter, - IOptions options) + AuthRedirectResolver redirectResolver) { + _authContext = authContext; _flow = flow; _deviceResolver = deviceResolver; - _tenantResolver = tenantResolver; _clock = clock; - _cookieManager = cookieManager; _credentialResponseWriter = credentialResponseWriter; - _options = options.Value; + _redirectResolver = redirectResolver; } public async Task LoginAsync(HttpContext ctx) { + var auth = _authContext.Current; + + var shouldIssueTokens = + auth.Response.AccessTokenDelivery.Mode != TokenResponseMode.None || + auth.Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; + if (!ctx.Request.HasFormContentType) return Results.BadRequest("Invalid content type."); @@ -50,7 +60,7 @@ public async Task LoginAsync(HttpContext ctx) var secret = form["Secret"].ToString(); if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret)) - return RedirectFailure(AuthFailureReason.InvalidCredentials); + return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, auth.OriginalOptions); var tenantCtx = ctx.GetTenantContext(); @@ -60,89 +70,62 @@ public async Task LoginAsync(HttpContext ctx) Secret = secret, TenantId = tenantCtx.TenantId, At = _clock.UtcNow, - DeviceInfo = _deviceResolver.Resolve(ctx) + DeviceInfo = _deviceResolver.Resolve(ctx), + RequestTokens = shouldIssueTokens }; - var result = await _flow.LoginAsync(flowRequest, ctx.RequestAborted); + var result = await _flow.LoginAsync(auth, flowRequest, ctx.RequestAborted); if (!result.IsSuccess) - return RedirectFailure(result.FailureReason ?? AuthFailureReason.Unknown); + return RedirectFailure(ctx, result.FailureReason ?? AuthFailureReason.Unknown, auth.OriginalOptions); if (result.SessionId is not null) { - _credentialResponseWriter.Write(ctx, result.SessionId.Value, _options.AuthResponse.SessionIdDelivery); + _credentialResponseWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); } - else if (result.AccessToken is not null) + + if (result.AccessToken is not null) { - _credentialResponseWriter.Write(ctx, result.AccessToken.Token, _options.AuthResponse.AccessTokenDelivery); + _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken.Token); } if (result.RefreshToken is not null) { - _credentialResponseWriter.Write(ctx, result.RefreshToken.Token, _options.AuthResponse.RefreshTokenDelivery); + _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken.Token); } - if (_options.AuthResponse.Login.RedirectEnabled) + if (auth.Response.Login.RedirectEnabled) { - return Results.Redirect(_options.AuthResponse.Login.SuccessRedirect); - } + var redirectUrl = + _redirectResolver.ResolveRedirect( + ctx, + auth.Response.Login.SuccessPath); - // TODO: Add PKCE, return result with body + return Results.Redirect(redirectUrl); + } + // PKCE / API login return Results.Ok(); } - private IResult RedirectFailure(AuthFailureReason reason) + private IResult RedirectFailure(HttpContext ctx, AuthFailureReason reason, UAuthServerOptions options) { - var redirect = _options.AuthResponse.Login; - - var code = (redirect.FailureCodes != null && redirect.FailureCodes.TryGetValue(reason, out var c)) - ? c - : "failed"; - - return Results.Redirect($"{redirect.FailureRedirect}?{redirect.FailureQueryKey}={code}"); + var login = options.AuthResponse.Login; + + var code = + login.FailureCodes != null && + login.FailureCodes.TryGetValue(reason, out var mapped) + ? mapped + : "failed"; + + var redirectUrl = _redirectResolver.ResolveRedirect( + ctx, + login.FailureRedirect, + new Dictionary + { + [login.FailureQueryKey] = code + }); + + return Results.Redirect(redirectUrl); } - - //public async Task LoginAsync(HttpContext ctx) - //{ - // var request = await ctx.Request.ReadFromJsonAsync(); - // if (request is null) - // return Results.BadRequest("Invalid login request."); - - // // Middleware should have already resolved the tenant - // var tenantCtx = ctx.GetTenantContext(); - - // var flowRequest = request with - // { - // TenantId = tenantCtx.TenantId, - // At = _clock.UtcNow, - // DeviceInfo = _deviceResolver.Resolve(ctx) - // }; - - // var result = await _flow.LoginAsync(flowRequest, ctx.RequestAborted); - - // if (result.IsSuccess) - // { - // _cookieManager.Issue(ctx, result.SessionId.Value); - // } - - // return result.Status switch - // { - // LoginStatus.Success => Results.Ok(new LoginResponse - // { - // SessionId = result.SessionId, - // AccessToken = result.AccessToken, - // RefreshToken = result.RefreshToken - // }), - - // LoginStatus.RequiresContinuation => Results.Ok(new LoginResponse - // { - // Continuation = result.Continuation - // }), - - // LoginStatus.Failed => Results.Unauthorized(), - - // _ => Results.StatusCode(StatusCodes.Status500InternalServerError) - // }; - //} } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs index 0743b6ba..d8127e92 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs @@ -1,61 +1,57 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Contracts; using CodeBeam.UltimateAuth.Server.Cookies; -using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Endpoints { public sealed class DefaultLogoutEndpointHandler : ILogoutEndpointHandler { + private readonly IAuthFlowContextAccessor _authContext; private readonly IUAuthFlowService _flow; private readonly IClock _clock; private readonly IUAuthCookieManager _cookieManager; - private readonly UAuthServerOptions _options; + private readonly AuthRedirectResolver _redirectResolver; - public DefaultLogoutEndpointHandler(IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, IOptions options) + public DefaultLogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, AuthRedirectResolver redirectResolver) { + _authContext = authContext; _flow = flow; _clock = clock; _cookieManager = cookieManager; - _options = options.Value; + _redirectResolver = redirectResolver; } public async Task LogoutAsync(HttpContext ctx) { - var tenantCtx = ctx.GetTenantContext(); - var sessionCtx = ctx.GetSessionContext(); + var auth = _authContext.Current; - if (sessionCtx.IsAnonymous) - return Results.Unauthorized(); - - var request = new LogoutRequest + if (auth.SessionId != null) { - TenantId = tenantCtx.TenantId, - SessionId = sessionCtx.SessionId!.Value, - At = _clock.UtcNow - }; + var request = new LogoutRequest + { + TenantId = auth.TenantId, + SessionId = auth.SessionId.Value, + At = _clock.UtcNow + }; - await _flow.LogoutAsync(request, ctx.RequestAborted); - _cookieManager.Delete(ctx); + await _flow.LogoutAsync(request, ctx.RequestAborted); + } - var logout = _options.AuthResponse.Logout; + DeleteIfCookie(ctx, auth.Response.SessionIdDelivery); + DeleteIfCookie(ctx, auth.Response.RefreshTokenDelivery); + DeleteIfCookie(ctx, auth.Response.AccessTokenDelivery); - if (logout.RedirectEnabled) + if (auth.Response.Logout.RedirectEnabled) { - var returnUrl = logout.AllowReturnUrlOverride - ? ctx.Request.Query["returnUrl"].FirstOrDefault() - : null; - - var redirect = !string.IsNullOrWhiteSpace(returnUrl) - ? returnUrl - : logout.RedirectUrl; - - // TODO: relative / same-origin check - return Results.Redirect(redirect); + var redirectUrl = _redirectResolver.ResolveRedirect(ctx, auth.Response.Logout.RedirectPath); + return Results.Redirect(redirectUrl); } return Results.Ok(new LogoutResponse @@ -63,5 +59,14 @@ public async Task LogoutAsync(HttpContext ctx) Success = true }); } + + private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery) + { + if (delivery.Mode != TokenResponseMode.Cookie) + return; + + _cookieManager.Delete(ctx, delivery.Cookie.Name); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs index b52db75a..6ee312b7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs @@ -1,100 +1,127 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Endpoints { public sealed class DefaultRefreshEndpointHandler : IRefreshEndpointHandler where TUserId : notnull { - private readonly UAuthServerOptions _options; - private readonly ISessionContextAccessor _sessionContextAccessor; - private readonly ISessionQueryService _sessionQueries; + private readonly IAuthFlowContextAccessor _authContext; private readonly ISessionRefreshService _sessionRefresh; - private readonly ICredentialResponseWriter _credentialResponseWriter; - private readonly IRefreshResponseWriter _refreshResponseWriter; + private readonly IRefreshTokenRotationService _tokenRotation; + private readonly ICredentialResponseWriter _credentialWriter; + private readonly IRefreshResponseWriter _refreshWriter; + private readonly ISessionQueryService _sessionQueries; + private readonly IRefreshTokenResolver _refreshTokenResolver; public DefaultRefreshEndpointHandler( - IOptions options, - ISessionContextAccessor sessionContextAccessor, - ISessionQueryService sessionQueries, + IAuthFlowContextAccessor authContext, ISessionRefreshService sessionRefresh, - ICredentialResponseWriter credentialResponseWriter, - IRefreshResponseWriter refreshResponseWriter) + IRefreshTokenRotationService tokenRotation, + ICredentialResponseWriter credentialWriter, + IRefreshResponseWriter refreshWriter, + ISessionQueryService sessionQueries, + IRefreshTokenResolver refreshTokenResolver) { - _options = options.Value; - _sessionContextAccessor = sessionContextAccessor; - _sessionQueries = sessionQueries; + _authContext = authContext; _sessionRefresh = sessionRefresh; - _credentialResponseWriter = credentialResponseWriter; - _refreshResponseWriter = refreshResponseWriter; + _tokenRotation = tokenRotation; + _credentialWriter = credentialWriter; + _refreshWriter = refreshWriter; + _sessionQueries = sessionQueries; + _refreshTokenResolver = refreshTokenResolver; } public async Task RefreshAsync(HttpContext ctx) { - var decision = RefreshDecisionResolver.Resolve(_options); + var auth = _authContext.Current; + var decision = RefreshDecisionResolver.Resolve(auth.EffectiveMode); - if (decision != RefreshDecision.SessionOnly) + return decision switch { - // Endpoint exists, but this mode does not support session refresh - return Results.StatusCode(StatusCodes.Status409Conflict); - } + RefreshDecision.SessionTouch => await HandleSessionTouchAsync(ctx, auth, auth.Response), + RefreshDecision.TokenRotation => await HandleTokenRotationAsync(ctx, auth, auth.Response), + + _ => Results.StatusCode(StatusCodes.Status409Conflict) + }; + } - var sessionContext = _sessionContextAccessor.Current; - if (sessionContext?.SessionId is null) + private async Task HandleSessionTouchAsync(HttpContext ctx, AuthFlowContext flow, EffectiveAuthResponse response) + { + if (flow.SessionId is null) return Results.Unauthorized(); var now = DateTimeOffset.UtcNow; var validation = await _sessionQueries.ValidateSessionAsync( new SessionValidationContext { - TenantId = sessionContext.TenantId, - SessionId = (AuthSessionId)sessionContext.SessionId, + TenantId = flow.TenantId, + SessionId = flow.SessionId.Value, Now = now, Device = DeviceInfoFactory.FromHttpContext(ctx) }, ctx.RequestAborted); - if (!validation.IsValid) { - if (_options.Diagnostics.EnableRefreshHeaders) - _refreshResponseWriter.Write(ctx, RefreshOutcome.ReauthRequired); + WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); return Results.Unauthorized(); } + var result = await _sessionRefresh.RefreshAsync(validation, now, ctx.RequestAborted); - var refreshResult = await _sessionRefresh.RefreshAsync(validation, now, ctx.RequestAborted); - - RefreshOutcome outcome; - - if (!refreshResult.IsSuccess || refreshResult.PrimaryToken is null) + if (!result.IsSuccess || result.PrimaryToken is null) { - outcome = RefreshOutcome.ReauthRequired; + WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); + return Results.Unauthorized(); + } - if (_options.Diagnostics.EnableRefreshHeaders) - _refreshResponseWriter.Write(ctx, outcome); + _credentialWriter.Write(ctx, CredentialKind.Session, result.PrimaryToken.Value); + WriteRefreshHeader(ctx, flow, result.DidTouch ? RefreshOutcome.Touched : RefreshOutcome.NoOp); + return Results.NoContent(); + } + + private async Task HandleTokenRotationAsync(HttpContext ctx, AuthFlowContext flow, EffectiveAuthResponse response) + { + var refreshToken = _refreshTokenResolver.Resolve(ctx); + if (refreshToken is null) return Results.Unauthorized(); - } - _credentialResponseWriter.Write(ctx, refreshResult.PrimaryToken.Value, - new CredentialResponseOptions + var now = DateTimeOffset.UtcNow; + + var result = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext { - Mode = TokenResponseMode.Cookie - }); + RefreshToken = refreshToken, + Now = DateTimeOffset.UtcNow + }, + ctx.RequestAborted); - outcome = refreshResult.DidTouch - ? RefreshOutcome.Touched - : RefreshOutcome.NoOp; + if (!result.IsSuccess) + { + WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); + return Results.Unauthorized(); + } - if (_options.Diagnostics.EnableRefreshHeaders) - _refreshResponseWriter.Write(ctx, outcome); + _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken.Token); + _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken.Token); + WriteRefreshHeader(ctx, flow, RefreshOutcome.Rotated); return Results.NoContent(); } + + private void WriteRefreshHeader(HttpContext ctx, AuthFlowContext flow, RefreshOutcome outcome) + { + if (!flow.OriginalOptions.Diagnostics.EnableRefreshHeaders) + return; + + _refreshWriter.Write(ctx, outcome); + } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs index 0b991751..33ab7252 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs @@ -1,8 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Abstractions; -using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; @@ -10,25 +10,27 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints { internal sealed class DefaultValidateEndpointHandler : IValidateEndpointHandler { - private readonly ICredentialResolver _credentialResolver; + private readonly IAuthFlowContextAccessor _authContext; + private readonly IFlowCredentialResolver _credentialResolver; private readonly ISessionQueryService _sessionValidator; private readonly IClock _clock; public DefaultValidateEndpointHandler( - ICredentialResolver credentialResolver, + IAuthFlowContextAccessor authContext, + IFlowCredentialResolver credentialResolver, ISessionQueryService sessionValidator, IClock clock) { + _authContext = authContext; _credentialResolver = credentialResolver; _sessionValidator = sessionValidator; _clock = clock; } - public async Task ValidateAsync( - HttpContext context, - CancellationToken ct = default) + public async Task ValidateAsync(HttpContext context, CancellationToken ct = default) { - var credential = _credentialResolver.Resolve(context); + var auth = _authContext.Current; + var credential = _credentialResolver.Resolve(context, auth.Response); if (credential is null) { @@ -69,7 +71,14 @@ public async Task ValidateAsync( return Results.Ok(new AuthValidationResult { IsValid = result.IsValid, - State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant() + State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant(), + Snapshot = new AuthStateSnapshot + { + UserId = result?.Session?.UserId?.ToString(), + TenantId = result?.TenantId, + Claims = result?.Session?.Claims ?? ClaimsSnapshot.Empty, + AuthenticatedAt = result?.Session?.CreatedAt, + } }); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/EndpointEnablement.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/EndpointEnablement.cs deleted file mode 100644 index 7863eaec..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/EndpointEnablement.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - internal static class EndpointEnablement - { - public static bool Resolve(bool? overrideValue, bool modeDefault) - => overrideValue ?? modeDefault; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs deleted file mode 100644 index 94c2cf9f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaultsMap.cs +++ /dev/null @@ -1,63 +0,0 @@ -using CodeBeam.UltimateAuth.Core; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - /// - /// Provides default endpoint enablement rules based on UAuthMode. - /// These defaults represent the secure and meaningful surface - /// for each authentication strategy. - /// - internal static class UAuthEndpointDefaultsMap - { - public static UAuthEndpointDefaults ForMode(UAuthMode? mode) - { - if (!mode.HasValue) - { - throw new InvalidOperationException( - "UAuthMode must be resolved before endpoint mapping. " + - "Ensure ClientProfile defaults are applied."); - } - - return mode switch - { - UAuthMode.PureOpaque => new UAuthEndpointDefaults - { - Login = true, - Pkce = false, - Token = false, - Session = true, - UserInfo = true - }, - - UAuthMode.Hybrid => new UAuthEndpointDefaults - { - Login = true, - Pkce = true, - Token = true, - Session = true, - UserInfo = true - }, - - UAuthMode.SemiHybrid => new UAuthEndpointDefaults - { - Login = true, - Pkce = true, - Token = true, - Session = false, - UserInfo = true - }, - - UAuthMode.PureJwt => new UAuthEndpointDefaults - { - Login = true, - Pkce = false, - Token = true, - Session = false, - UserInfo = true - }, - - _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) - }; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 87079895..728a8a6d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Server.Options; +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; @@ -12,12 +14,11 @@ public interface IAuthEndpointRegistrar } // TODO: Add Scalar/Swagger integration + // TODO: Add endpoint based guards public class UAuthEndpointRegistrar : IAuthEndpointRegistrar { public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) { - var defaults = UAuthEndpointDefaultsMap.ForMode(options.Mode); - // Base: /auth string basePrefix = options.RoutePrefix.TrimStart('/'); @@ -29,87 +30,89 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options ? rootGroup.MapGroup("/{tenant}/" + basePrefix) : rootGroup.MapGroup("/" + basePrefix); - if (EndpointEnablement.Resolve(options.EnablePkceEndpoints, defaults.Pkce)) + group.AddEndpointFilter(); + + if (options.EnablePkceEndpoints != false) { var pkce = group.MapGroup("/pkce"); pkce.MapPost("/create", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.CreateAsync(ctx)); + => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); pkce.MapPost("/verify", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.VerifyAsync(ctx)); + => await h.VerifyAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); pkce.MapPost("/consume", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.ConsumeAsync(ctx)); + => await h.ConsumeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); } - if (EndpointEnablement.Resolve(options.EnableLoginEndpoints, defaults.Login)) + if (options.EnableLoginEndpoints != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) - => await h.LoginAsync(ctx)); + => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) - => await h.ValidateAsync(ctx)); + => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)); group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutAsync(ctx)); + => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) - => await h.RefreshAsync(ctx)); + => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) - => await h.ReauthAsync(ctx)); + => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); } - if (EndpointEnablement.Resolve(options.EnableTokenEndpoints, defaults.Token)) + if (options.EnableTokenEndpoints != false) { var token = group.MapGroup(""); token.MapPost("/token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.GetTokenAsync(ctx)); + => await h.GetTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IssueToken)); token.MapPost("/refresh-token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.RefreshTokenAsync(ctx)); + => await h.RefreshTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshToken)); token.MapPost("/introspect", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.IntrospectAsync(ctx)); + => await h.IntrospectAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IntrospectToken)); token.MapPost("/revoke", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.RevokeAsync(ctx)); + => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); } - if (EndpointEnablement.Resolve(options.EnableSessionEndpoints, defaults.Session)) + if (options.EnableSessionEndpoints != false) { var session = group.MapGroup("/session"); session.MapGet("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetCurrentSessionAsync(ctx)); + => await h.GetCurrentSessionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); session.MapGet("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetAllSessionsAsync(ctx)); + => await h.GetAllSessionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); session.MapPost("/revoke/{sessionId}", async ([FromServices] ISessionManagementHandler h, string sessionId, HttpContext ctx) - => await h.RevokeSessionAsync(sessionId, ctx)); + => await h.RevokeSessionAsync(sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); session.MapPost("/revoke-all", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.RevokeAllAsync(ctx)); + => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); } - if (EndpointEnablement.Resolve(options.EnableUserInfoEndpoints, defaults.UserInfo)) + if (options.EnableUserInfoEndpoints != false) { var user = group.MapGroup(""); user.MapGet("/userinfo", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - => await h.GetUserInfoAsync(ctx)); + => await h.GetUserInfoAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); user.MapGet("/permissions", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - => await h.GetPermissionsAsync(ctx)); + => await h.GetPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); user.MapPost("/permissions/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - => await h.CheckPermissionAsync(ctx)); + => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs new file mode 100644 index 00000000..2a2d25ae --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Middlewares; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions +{ + public static class HttpContextUserExtensions + { + public static AuthUserSnapshot GetUserContext(this HttpContext ctx) + { + if (ctx.Items.TryGetValue(UserMiddleware.UserContextKey, out var value) && value is AuthUserSnapshot user) + { + return user; + } + + return AuthUserSnapshot.Anonymous(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs new file mode 100644 index 00000000..61ad1d3f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs @@ -0,0 +1,13 @@ +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/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index 60113f9f..22335841 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -1,11 +1,12 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; 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.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -49,25 +50,24 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) { - services.AddSingleton(); - services.PostConfigure(o => - { - if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) - return; - - using var sp = services.BuildServiceProvider(); - var detector = sp.GetRequiredService(); - o.ClientProfile = detector.Detect(sp); - }); - - services.AddOptions() - .PostConfigure>((server, core) => - { - ConfigureDefaults.ApplyClientProfileDefaults(server, core.Value); - ConfigureDefaults.ApplyModeDefaults(server); - ConfigureDefaults.ApplyAuthResponseDefaults(server, core.Value); - }); - + //services.AddSingleton(); + //services.PostConfigure(o => + //{ + // if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) + // return; + + // using var sp = services.BuildServiceProvider(); + // var detector = sp.GetRequiredService(); + // o.ClientProfile = detector.Detect(sp); + //}); + + //services.AddOptions() + // .PostConfigure>((server, core) => + // { + // ConfigureDefaults.ApplyClientProfileDefaults(server, core.Value); + // ConfigureDefaults.ApplyModeDefaults(server); + // ConfigureDefaults.ApplyAuthResponseDefaults(server, core.Value); + // }); services.TryAddSingleton(); services.TryAddSingleton(); @@ -115,12 +115,12 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol }); // Inner resolvers + services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); - // Public resolver (tek!) + // Public resolver services.AddScoped(); services.TryAddScoped(); @@ -133,7 +133,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddSingleton(); // TODO: Allow custom cookie manager via options - services.AddSingleton(); + //services.AddSingleton(); //if (options.CustomCookieManagerType is not null) //{ // services.AddSingleton(typeof(IUAuthSessionCookieManager), options.CustomCookieManagerType); @@ -156,18 +156,48 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(typeof(ISessionOrchestrator<>), typeof(UAuthSessionOrchestrator<>)); services.TryAddScoped(); services.TryAddScoped(typeof(ISessionQueryService<>), typeof(UAuthSessionQueryService<>)); - services.TryAddScoped(typeof(IRefreshTokenResolver<>), typeof(UAuthRefreshTokenResolver<>)); + services.TryAddScoped(typeof(IRefreshTokenResolver), typeof(DefaultRefreshTokenResolver)); services.TryAddScoped(typeof(ISessionRefreshService<>), typeof(DefaultSessionRefreshService<>)); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.AddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.AddScoped(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddSingleton(); + services.AddScoped(); + + services.AddScoped(typeof(IRefreshTokenValidator<>), typeof(DefaultRefreshTokenValidator<>)); + services.AddScoped(typeof(IRefreshTokenRotationService<>), typeof(RefreshTokenRotationService<>)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + // ----------------------------- // ENDPOINTS // ----------------------------- + services.AddHttpContextAccessor(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + + services.TryAddSingleton(); // Endpoint handlers //services.TryAddScoped(typeof(ILoginEndpointHandler), typeof(DefaultLoginEndpointHandler<>)); @@ -207,7 +237,7 @@ public static IServiceCollection AddUAuthServerInfrastructure(this IServiceColle services.TryAddSingleton(); // Cookie management (default) - services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs new file mode 100644 index 00000000..c0ed6ffe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs @@ -0,0 +1,65 @@ +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class AuthRedirectResolver + { + private readonly UAuthServerOptions _options; + + public AuthRedirectResolver(IOptions options) + { + _options = options.Value; + } + + // TODO: Add allowed origins validation + public string ResolveClientBase(HttpContext ctx) + { + if (ctx.Request.Query.TryGetValue("returnUrl", out var returnUrl) && + Uri.TryCreate(returnUrl!, UriKind.Absolute, out var ru)) + { + return ru.GetLeftPart(UriPartial.Authority); + } + + if (ctx.Request.Headers.TryGetValue("Origin", out var origin) && + Uri.TryCreate(origin!, UriKind.Absolute, out var originUri)) + { + return originUri.GetLeftPart(UriPartial.Authority); + } + + if (ctx.Request.Headers.TryGetValue("Referer", out var referer) && + Uri.TryCreate(referer!, UriKind.Absolute, out var refUri)) + { + return refUri.GetLeftPart(UriPartial.Authority); + } + + if (!string.IsNullOrWhiteSpace(_options.Hub.ClientBaseAddress)) + return _options.Hub.ClientBaseAddress; + + return $"{ctx.Request.Scheme}://{ctx.Request.Host}"; + } + + public string ResolveRedirect(HttpContext ctx, string path, IDictionary? query = null) + { + var url = Combine(ResolveClientBase(ctx), path); + + 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}"; + } + + private static string Combine(string baseUri, string path) + { + return baseUri.TrimEnd('/') + "/" + path.TrimStart('/'); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs deleted file mode 100644 index 3a8aafa4..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs +++ /dev/null @@ -1,56 +0,0 @@ -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 CompositeSessionIdResolver : ISessionIdResolver - { - private readonly IReadOnlyList _resolvers; - - public CompositeSessionIdResolver(IEnumerable resolvers, IOptions options) - { - _resolvers = Order(resolvers, options.Value); - } - - public AuthSessionId? Resolve(HttpContext context) - { - foreach (var resolver in _resolvers) - { - var id = resolver.Resolve(context); - if (id is not null) - return id; - } - - return null; - } - - private static IReadOnlyList Order(IEnumerable resolvers, UAuthServerOptions options) - { - var list = resolvers.ToList(); - - if (options.SessionResolution.Order is null || options.SessionResolution.Order.Count == 0) - return list; - - var map = list.ToDictionary( - r => r.GetType().Name.Replace("SessionIdResolver", ""), - r => r, - StringComparer.OrdinalIgnoreCase); - - var ordered = new List(); - - foreach (var key in options.SessionResolution.Order) - { - if (map.TryGetValue(key, out var r)) - ordered.Add(r); - } - - if (ordered.Count == 0) - return list; - - return ordered; - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs new file mode 100644 index 00000000..5eb344c5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs @@ -0,0 +1,169 @@ +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 DefaultTransportCredentialResolver : ITransportCredentialResolver + { + private readonly IOptionsMonitor _server; + + public DefaultTransportCredentialResolver(IOptionsMonitor server) + { + _server = server; + } + + public TransportCredential? Resolve(HttpContext context) + { + var cookies = _server.CurrentValue.Cookie; + + // 1️⃣ Authorization header (Bearer) + if (TryFromAuthorizationHeader(context, out var bearer)) + return bearer; + + // 2️⃣ Cookies (session / refresh / access) + if (TryFromCookies(context, cookies, out var cookie)) + return cookie; + + // 3️⃣ Query (legacy / special flows) + if (TryFromQuery(context, out var query)) + return query; + + // 4️⃣ Body (rare, but possible – PKCE / device flows) + if (TryFromBody(context, out var body)) + return body; + + // 5️⃣ Hub / external authority + if (TryFromHub(context, out var hub)) + return hub; + + return null; + } + + // ---------- resolvers ---------- + + // TODO: Make scheme configurable, shouldn't be hard coded + private static bool TryFromAuthorizationHeader(HttpContext ctx, out TransportCredential credential) + { + credential = default!; + + if (!ctx.Request.Headers.TryGetValue("Authorization", out var header)) + return false; + + var value = header.ToString(); + if (!value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return false; + + var token = value["Bearer ".Length..].Trim(); + if (string.IsNullOrWhiteSpace(token)) + return false; + + credential = new TransportCredential + { + Kind = TransportCredentialKind.AccessToken, + Value = token, + TenantId = ctx.GetTenantContext().TenantId, + Device = ctx.GetDevice() + }; + + return true; + } + + private static bool TryFromCookies( + HttpContext ctx, + UAuthCookieSetOptions cookieSet, + out TransportCredential credential) + { + credential = default!; + + // Session cookie + if (TryReadCookie(ctx, cookieSet.Session.Name, out var session)) + { + credential = Build(ctx, TransportCredentialKind.Session, session); + return true; + } + + // Refresh token cookie + if (TryReadCookie(ctx, cookieSet.RefreshToken.Name, out var refresh)) + { + credential = Build(ctx, TransportCredentialKind.RefreshToken, refresh); + return true; + } + + // Access token cookie (optional) + if (TryReadCookie(ctx, cookieSet.AccessToken.Name, out var access)) + { + credential = Build(ctx, TransportCredentialKind.AccessToken, access); + return true; + } + + return false; + } + + private static bool TryFromQuery(HttpContext ctx, out TransportCredential credential) + { + credential = default!; + + if (!ctx.Request.Query.TryGetValue("access_token", out var token)) + return false; + + var value = token.ToString(); + if (string.IsNullOrWhiteSpace(value)) + return false; + + credential = new TransportCredential + { + Kind = TransportCredentialKind.AccessToken, + Value = value, + TenantId = ctx.GetTenantContext().TenantId, + Device = ctx.GetDevice() + }; + + return true; + } + + private static bool TryFromBody(HttpContext ctx, out TransportCredential credential) + { + credential = default!; + // intentionally empty for now + // body parsing is expensive and opt-in later + return false; + } + + private static bool TryFromHub(HttpContext ctx, out TransportCredential credential) + { + credential = default!; + // UAuthHub detection can live here later + return false; + } + + 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 TransportCredential Build(HttpContext ctx, TransportCredentialKind kind, string value) + => new() + { + Kind = kind, + Value = value, + TenantId = ctx.GetTenantContext().TenantId, + Device = ctx.GetDevice() + }; + + } +} 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..593e592c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface ITransportCredentialResolver + { + TransportCredential? Resolve(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..93e78b3d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +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 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..2638040c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public enum TransportCredentialKind + { + Session, + AccessToken, + RefreshToken, + Hub + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultFlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultFlowCredentialResolver.cs new file mode 100644 index 00000000..921447aa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultFlowCredentialResolver.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +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 DefaultFlowCredentialResolver : IFlowCredentialResolver + { + private readonly IPrimaryCredentialResolver _primaryResolver; + + public DefaultFlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) + { + _primaryResolver = primaryResolver; + } + + public ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response) + { + var kind = _primaryResolver.Resolve(context); + + return kind switch + { + PrimaryCredentialKind.Stateful => ResolveSession(context, response), + PrimaryCredentialKind.Stateless => ResolveAccessToken(context, response), + + _ => null + }; + } + + private static ResolvedCredential? 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 = PrimaryCredentialKind.Stateful, + Value = raw.Trim(), + TenantId = context.GetTenantContext().TenantId, + Device = context.GetDevice() + }; + } + + private static ResolvedCredential? 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 = PrimaryCredentialKind.Stateless, + Value = value, + TenantId = context.GetTenantContext().TenantId, + Device = context.GetDevice() + }; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthBodyPolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthBodyPolicyBuilder.cs new file mode 100644 index 00000000..4b35299c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthBodyPolicyBuilder.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal class DefaultUAuthBodyPolicyBuilder : IUAuthBodyPolicyBuilder + { + public object BuildBodyValue(string rawValue, CredentialResponseOptions response, AuthFlowContext context) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthHeaderPolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthHeaderPolicyBuilder.cs new file mode 100644 index 00000000..0e628c34 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthHeaderPolicyBuilder.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class DefaultUAuthHeaderPolicyBuilder : 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/IFlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs new file mode 100644 index 00000000..9c849c63 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +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 IFlowCredentialResolver + { + ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response); + } +} 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/DefaultCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResolver.cs deleted file mode 100644 index 7b397cbd..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResolver.cs +++ /dev/null @@ -1,77 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Abstractions; -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 DefaultCredentialResolver : ICredentialResolver - { - private readonly IPrimaryCredentialResolver _primaryResolver; - private readonly UAuthServerOptions _options; - - public DefaultCredentialResolver( - IPrimaryCredentialResolver primaryResolver, - IOptions options) - { - _primaryResolver = primaryResolver; - _options = options.Value; - } - - public ResolvedCredential? Resolve(HttpContext context) - { - var primary = _primaryResolver.Resolve(context); - - return primary switch - { - PrimaryCredentialKind.Stateful => ResolveSession(context), - PrimaryCredentialKind.Stateless => ResolveToken(context), - _ => null - }; - } - - private ResolvedCredential? ResolveSession(HttpContext context) - { - if (!context.Request.Cookies.TryGetValue( - _options.Cookie.Name, - out var sessionId)) - { - return null; - } - - return new ResolvedCredential - { - Kind = PrimaryCredentialKind.Stateful, - Value = sessionId, - TenantId = context.GetTenantContext().TenantId, - Device = context.GetDevice() - }; - } - - private ResolvedCredential? ResolveToken(HttpContext context) - { - if (!context.Request.Headers.TryGetValue("Authorization", out var header)) - return null; - - var value = header.ToString(); - - if (value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - value = value["Bearer ".Length..].Trim(); - } - - if (string.IsNullOrWhiteSpace(value)) - return null; - - return new ResolvedCredential - { - Kind = PrimaryCredentialKind.Stateless, - Value = value, - TenantId = context.GetTenantContext().TenantId, - Device = context.GetDevice() - }; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs index 92df1111..5a087d77 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs @@ -1,57 +1,101 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; -using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Cookies; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server; + +internal sealed class DefaultCredentialResponseWriter : ICredentialResponseWriter { - internal sealed class DefaultCredentialResponseWriter : ICredentialResponseWriter + private readonly IAuthFlowContextAccessor _authContext; + private readonly IUAuthCookieManager _cookieManager; + private readonly IUAuthCookiePolicyBuilder _cookiePolicy; + private readonly IUAuthHeaderPolicyBuilder _headerPolicy; + + public DefaultCredentialResponseWriter( + IAuthFlowContextAccessor authContext, + IUAuthCookieManager cookieManager, + IUAuthCookiePolicyBuilder cookiePolicy, + IUAuthHeaderPolicyBuilder headerPolicy) + { + _authContext = authContext; + _cookieManager = cookieManager; + _cookiePolicy = cookiePolicy; + _headerPolicy = headerPolicy; + } + + public void Write(HttpContext context, CredentialKind kind, string value) { - private readonly IUAuthCookieManager _cookieManager; + var auth = _authContext.Current; + var delivery = ResolveDelivery(auth.Response, kind); + - public DefaultCredentialResponseWriter( - IUAuthCookieManager cookieManager) + if (delivery.Mode == TokenResponseMode.None) + return; + + switch (delivery.Mode) { - _cookieManager = cookieManager; + 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, CredentialKind kind, string value, CredentialResponseOptions options, AuthFlowContext auth) + { + if (options.Cookie is null) + throw new InvalidOperationException($"Cookie options missing for credential '{kind}'."); - public void Write(HttpContext context, string value, CredentialResponseOptions options) + var logicalLifetime = ResolveLogicalLifetime(auth, kind); + var cookieOptions = _cookiePolicy.Build(options, auth, logicalLifetime); + + _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, CredentialKind kind) + => kind switch { - switch (options.Mode) - { - case TokenResponseMode.Cookie: - _cookieManager.Write(context, value); - break; - - case TokenResponseMode.Header: - WriteHeader(context, value, options); - break; - - case TokenResponseMode.Body: - // Intentionally NO-OP here. - // Body is composed by the endpoint response. - break; - - case TokenResponseMode.None: - default: - break; - } - } + CredentialKind.Session => response.SessionIdDelivery, + CredentialKind.AccessToken => response.AccessTokenDelivery, + CredentialKind.RefreshToken => response.RefreshTokenDelivery, + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; - private static void WriteHeader( HttpContext context, string value, CredentialResponseOptions options) + private static TimeSpan? ResolveLogicalLifetime(AuthFlowContext auth, CredentialKind kind) + { + // TODO: Move this method to policy on implementing + return kind switch { - var headerName = options.Name ?? "Authorization"; + CredentialKind.Session + => auth.EffectiveOptions.Options.Session.IdleTimeout + auth.OriginalOptions.Cookie.Session.Lifetime.IdleBuffer, - var formatted = options.HeaderFormat switch - { - HeaderTokenFormat.Bearer => $"Bearer {value}", - HeaderTokenFormat.Raw => value, - _ => value - }; + CredentialKind.RefreshToken + => auth.EffectiveOptions.Options.Tokens.RefreshTokenLifetime + auth.OriginalOptions.Cookie.RefreshToken.Lifetime.IdleBuffer, - context.Response.Headers[headerName] = formatted; - } + CredentialKind.AccessToken + => auth.EffectiveOptions.Options.Tokens.AccessTokenLifetime + auth.OriginalOptions.Cookie.AccessToken.Lifetime.IdleBuffer, + + _ => null + }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs index 0c75accc..861c72a0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public interface IInnerSessionIdResolver { + string Key { get; } AuthSessionId? Resolve(HttpContext context); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs deleted file mode 100644 index 803bb4b9..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - /// - /// Issues access and refresh tokens according to the active auth mode. - /// Does not perform persistence or validation. - /// - public interface ITokenIssuer - { - Task IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs index 638449a7..ea9e1460 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs @@ -40,9 +40,10 @@ public async Task> ValidateSessionAsync(Session if (session.SecurityVersionAtCreation != root.SecurityVersion) return SessionValidationResult.Invalid(SessionState.SecurityMismatch); - // TODO: Implement AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. - if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) - return SessionValidationResult.Invalid(SessionState.DeviceMismatch); + // TODO: Implement device id, AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. + // Currently this line has error on refresh flow. + //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) + // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); return SessionValidationResult.Active(context.TenantId, session, chain, root); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs new file mode 100644 index 00000000..13fe2cfc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs @@ -0,0 +1,44 @@ +using CodeBeam.UltimateAuth.Server.Abstractions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class DefaultRefreshTokenResolver : IRefreshTokenResolver + { + private const string DefaultCookieName = "ua_refresh"; + private const string BearerPrefix = "Bearer "; + private const string RefreshHeaderName = "X-Refresh-Token"; + + public string? Resolve(HttpContext context) + { + // 1️⃣ Cookie (preferred) + if (context.Request.Cookies.TryGetValue(DefaultCookieName, out var cookieToken) && + !string.IsNullOrWhiteSpace(cookieToken)) + { + return cookieToken; + } + + // 2️⃣ Authorization: Bearer + 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; + } + } + + // 3️⃣ Explicit header fallback + if (context.Request.Headers.TryGetValue(RefreshHeaderName, out var headerToken) && + !string.IsNullOrWhiteSpace(headerToken)) + { + return headerToken.ToString(); + } + + return null; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs index 7001b549..1a9ccec6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs @@ -8,26 +8,23 @@ public enum RefreshDecision { /// - /// Refresh is not supported for this mode. + /// Refresh endpoint is disabled for this mode. /// NotSupported = 0, /// - /// Only session lifecycle can be refreshed. + /// Only session lifetime is extended. + /// No access / refresh token issued. /// (PureOpaque) /// - SessionOnly = 1, + SessionTouch = 1, /// - /// Session lifecycle + token issuance can be refreshed. - /// (Hybrid) + /// Refresh token is rotated and + /// a new access token is issued. + /// Session MAY also be touched depending on policy. + /// (Hybrid, SemiHybrid, PureJwt) /// - SessionAndToken = 2, - - /// - /// Only token lifecycle can be refreshed. - /// (SemiHybrid, PureJwt) - /// - TokenOnly = 3 + TokenRotation = 2 } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs index ae227cf4..b5e0b373 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs @@ -1,22 +1,21 @@ using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Server.Options; - namespace CodeBeam.UltimateAuth.Server.Infrastructure { /// - /// Resolves refresh behavior based on AuthMode and server options. + /// 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(UAuthServerOptions options) + public static RefreshDecision Resolve(UAuthMode mode) { - return options.Mode switch + return mode switch { - UAuthMode.PureOpaque => RefreshDecision.SessionOnly, - UAuthMode.Hybrid => RefreshDecision.SessionAndToken, - UAuthMode.SemiHybrid => RefreshDecision.TokenOnly, - UAuthMode.PureJwt => RefreshDecision.TokenOnly, + UAuthMode.PureOpaque => RefreshDecision.SessionTouch, + + UAuthMode.Hybrid + or UAuthMode.SemiHybrid + or UAuthMode.PureJwt => RefreshDecision.TokenRotation, _ => RefreshDecision.NotSupported }; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs index c9fd6947..9caab359 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs @@ -5,6 +5,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class BearerSessionIdResolver : IInnerSessionIdResolver { + public string Key => "bearer"; + public AuthSessionId? Resolve(HttpContext context) { var header = context.Request.Headers.Authorization.ToString(); @@ -21,4 +23,5 @@ public sealed class BearerSessionIdResolver : IInnerSessionIdResolver return new AuthSessionId(raw); } } + } 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..978a55f1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + // TODO: Add policy and effective auth resolver. + public sealed class CompositeSessionIdResolver : ISessionIdResolver + { + private readonly IReadOnlyList _resolvers; + + public CompositeSessionIdResolver(IEnumerable resolvers) + { + _resolvers = resolvers.ToList(); + } + + public AuthSessionId? Resolve(HttpContext context) + { + foreach (var resolver in _resolvers) + { + var id = resolver.Resolve(context); + if (id is not null) + return id; + } + + return null; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs similarity index 80% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs index a0cd09f6..b1988ae5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs @@ -7,6 +7,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class CookieSessionIdResolver : IInnerSessionIdResolver { + public string Key => "cookie"; + private readonly UAuthServerOptions _options; public CookieSessionIdResolver(IOptions options) @@ -16,13 +18,14 @@ public CookieSessionIdResolver(IOptions options) public AuthSessionId? Resolve(HttpContext context) { - if (!context.Request.Cookies.TryGetValue(_options.Cookie.Name, out var raw)) + var cookieName = _options.Cookie.Session.Name; + + if (!context.Request.Cookies.TryGetValue(cookieName, out var raw)) return null; return string.IsNullOrWhiteSpace(raw) ? null : new AuthSessionId(raw.Trim()); } - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs similarity index 95% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs index 8ee19833..0ea9eee4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs @@ -7,6 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class HeaderSessionIdResolver : IInnerSessionIdResolver { + public string Key => "header"; private readonly UAuthServerOptions _options; public HeaderSessionIdResolver(IOptions options) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs similarity index 95% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs index cd615db8..c0b1e7f9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs @@ -7,6 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class QuerySessionIdResolver : IInnerSessionIdResolver { + public string Key => "query"; private readonly UAuthServerOptions _options; public QuerySessionIdResolver(IOptions options) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs index 940bbccf..7e9a6d9f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs @@ -25,39 +25,19 @@ public async Task ResolveAsync(HttpContext context) if (sessionCtx.IsAnonymous) { - context.Items[UserMiddleware.UserContextKey] = - UserContext.Anonymous(); + context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Anonymous(); return; } - // 🔐 Load & validate session - var session = await _sessionStore.GetSessionAsync( - sessionCtx.TenantId, - sessionCtx.SessionId!.Value); + var session = await _sessionStore.GetSessionAsync(sessionCtx.TenantId, sessionCtx.SessionId!.Value); if (session is null || session.IsRevoked) { - context.Items[UserMiddleware.UserContextKey] = - UserContext.Anonymous(); + context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Anonymous(); return; } - // 👤 Load user - var user = await _userStore.FindByIdAsync(sessionCtx.TenantId, session.UserId); - - if (user is null) - { - context.Items[UserMiddleware.UserContextKey] = - UserContext.Anonymous(); - return; - } - - context.Items[UserMiddleware.UserContextKey] = - new UserContext - { - UserId = session.UserId, - User = user - }; + context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Authenticated(session.UserId); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs index 5aec4c0b..adee7737 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs @@ -2,10 +2,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; -using System.Security.Claims; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Auth; namespace CodeBeam.UltimateAuth.Server.Issuers { @@ -19,54 +18,50 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; - private readonly UAuthServerOptions _options; private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IOptions options, IClock clock) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; - _options = options.Value; _clock = clock; } - public Task IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) + public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) { - var now = DateTimeOffset.UtcNow; - var expires = now.Add(_options.Tokens.AccessTokenLifetime); + var tokens = flow.OriginalOptions.Tokens; + var now = _clock.UtcNow; + var expires = now.Add(tokens.AccessTokenLifetime); - return _options.Mode switch + return flow.EffectiveMode switch { - UAuthMode.PureOpaque => Task.FromResult(IssueOpaqueAccessToken( - expires, - context.SessionId)), + UAuthMode.PureOpaque or UAuthMode.Hybrid => + Task.FromResult(IssueOpaqueAccessToken(expires, context.SessionId)), - UAuthMode.Hybrid or UAuthMode.SemiHybrid or - UAuthMode.PureJwt => Task.FromResult(IssueJwtAccessToken( - context, - expires)), + UAuthMode.PureJwt => + Task.FromResult(IssueJwtAccessToken(context, tokens, expires)), _ => throw new InvalidOperationException( - $"Unsupported auth mode: {_options.Mode}") + $"Unsupported auth mode: {flow.EffectiveMode}") }; } - public Task IssueRefreshTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) + public Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) { - if (_options.Mode == UAuthMode.PureOpaque) + if (flow.EffectiveMode == UAuthMode.PureOpaque) return Task.FromResult(null); - var now = DateTimeOffset.UtcNow; - var expires = now.Add(_options.Tokens.RefreshTokenLifetime); + var tokens = flow.OriginalOptions.Tokens; + var expires = _clock.UtcNow.Add(tokens.RefreshTokenLifetime); - string token = _opaqueGenerator.Generate(); - string hash = _tokenHasher.Hash(token); + var raw = _opaqueGenerator.Generate(); + var hash = _tokenHasher.Hash(raw); return Task.FromResult(new RefreshToken { - Token = token, + Token = raw, TokenHash = hash, ExpiresAt = expires }); @@ -85,7 +80,7 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi }; } - private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, DateTimeOffset expires) + private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthTokenOptions tokens, DateTimeOffset expires) { var claims = new Dictionary { @@ -94,33 +89,27 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, DateTimeOf }; foreach (var kv in context.Claims) - { claims[kv.Key] = kv.Value; - } if (!string.IsNullOrWhiteSpace(context.SessionId)) - { claims["sid"] = context.SessionId!; - } - if (_options.Tokens.AddJwtIdClaim) - { + if (tokens.AddJwtIdClaim) claims["jti"] = _opaqueGenerator.Generate(16); - } var descriptor = new UAuthJwtTokenDescriptor { Subject = context.UserId, - Issuer = _options.Tokens.Issuer, - Audience = _options.Tokens.Audience, + Issuer = tokens.Issuer, + Audience = tokens.Audience, IssuedAt = _clock.UtcNow, ExpiresAt = expires, TenantId = context.TenantId, Claims = claims, - KeyId = _options.Tokens.KeyId + KeyId = tokens.KeyId }; - string jwt = _jwtGenerator.CreateToken(descriptor); + var jwt = _jwtGenerator.CreateToken(descriptor); return new AccessToken { diff --git a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs index 55ab9e21..41d5a0d1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Server.Options { public sealed class AuthResponseOptions { @@ -10,5 +8,15 @@ public sealed class AuthResponseOptions public LoginRedirectOptions Login { get; set; } = new(); public LogoutRedirectOptions Logout { get; set; } = new(); + + internal AuthResponseOptions 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/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs index dc16419d..ba0ef111 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -1,10 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Contracts; +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 CredentialKind Kind { get; init; } public TokenResponseMode Mode { get; set; } = TokenResponseMode.None; /// @@ -16,5 +18,30 @@ public sealed class CredentialResponseOptions /// 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) + => new() + { + Kind = Kind, + Mode = Mode, + Name = Name, + HeaderFormat = HeaderFormat, + TokenFormat = TokenFormat, + Cookie = cookie + }; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs index 76e0ed16..e96fbef0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -1,45 +1,11 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Contracts; namespace CodeBeam.UltimateAuth.Server.Options { internal class ConfigureDefaults { - internal static void ApplyClientProfileDefaults(UAuthServerOptions o, UAuthOptions core) - { - if (core.ClientProfile == UAuthClientProfile.NotSpecified) - { - o.Mode ??= UAuthMode.Hybrid; - return; - } - - if (o.Mode is null) - { - o.Mode = core.ClientProfile switch - { - UAuthClientProfile.BlazorServer => UAuthMode.PureOpaque, - UAuthClientProfile.BlazorWasm => UAuthMode.SemiHybrid, - UAuthClientProfile.Maui => UAuthMode.SemiHybrid, - UAuthClientProfile.Mvc => UAuthMode.Hybrid, - UAuthClientProfile.Api => UAuthMode.PureJwt, - _ => throw new InvalidOperationException("Unsupported client profile. Please specify a client profile or make sure it's set NotSpecified") - }; - } - - if (o.HubDeploymentMode == default) - { - o.HubDeploymentMode = core.ClientProfile switch - { - UAuthClientProfile.BlazorWasm => UAuthHubDeploymentMode.Integrated, - UAuthClientProfile.Maui => UAuthHubDeploymentMode.Integrated, - _ => UAuthHubDeploymentMode.Embedded - }; - } - } - internal static void ApplyModeDefaults(UAuthServerOptions o) { switch (o.Mode) @@ -65,76 +31,12 @@ internal static void ApplyModeDefaults(UAuthServerOptions o) } } - internal static void ApplyAuthResponseDefaults(UAuthServerOptions o, UAuthOptions core) - { - var ar = o.AuthResponse; - if (ar is null) - return; - - bool sessionNotSet = ar.SessionIdDelivery.Mode == TokenResponseMode.None; - bool accessNotSet = ar.AccessTokenDelivery.Mode == TokenResponseMode.None; - bool refreshNotSet = ar.RefreshTokenDelivery.Mode == TokenResponseMode.None; - - if (!sessionNotSet || !accessNotSet || !refreshNotSet) - return; - - switch (core.ClientProfile) - { - // TODO: Change NotSpecified option defaults. Should be same as BlazorWasm. - case UAuthClientProfile.NotSpecified: - ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; - ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; - ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.None }; - ar.Login.RedirectEnabled = true; - ar.Logout.RedirectEnabled = true; - break; - case UAuthClientProfile.BlazorServer: - ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; - ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; - ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.None }; - ar.Login.RedirectEnabled = true; - ar.Logout.RedirectEnabled = true; - break; - - case UAuthClientProfile.BlazorWasm: - ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; - ar.Login.RedirectEnabled = true; - ar.Logout.RedirectEnabled = true; - break; - - case UAuthClientProfile.Maui: - ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.Login.RedirectEnabled = true; - ar.Logout.RedirectEnabled = true; - break; - - case UAuthClientProfile.Mvc: - ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Cookie }; - ar.Login.RedirectEnabled = true; - ar.Logout.RedirectEnabled = true; - break; - - case UAuthClientProfile.Api: - ar.SessionIdDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.AccessTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.RefreshTokenDelivery = new CredentialResponseOptions() { Mode = TokenResponseMode.Header, HeaderFormat = HeaderTokenFormat.Bearer }; - ar.Login.RedirectEnabled = false; - ar.Logout.RedirectEnabled = false; - break; - } - } - private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) { var s = o.Session; var t = o.Tokens; var c = o.Cookie; + var r = o.AuthResponse; // Session behavior s.SlidingExpiration = true; @@ -158,7 +60,13 @@ private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) // Refresh token does not exist in PureOpaque t.IssueRefresh = false; - c.IdleBuffer = TimeSpan.FromDays(2); + c.Session.Lifetime.IdleBuffer = TimeSpan.FromDays(2); + + r.RefreshTokenDelivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.None, + TokenFormat = TokenFormat.Opaque + }; } private static void ApplyHybridDefaults(UAuthServerOptions o) @@ -166,6 +74,7 @@ private static void ApplyHybridDefaults(UAuthServerOptions o) var s = o.Session; var t = o.Tokens; var c = o.Cookie; + var r = o.AuthResponse; s.SlidingExpiration = true; s.TouchInterval = null; @@ -175,7 +84,14 @@ private static void ApplyHybridDefaults(UAuthServerOptions o) t.AccessTokenLifetime = TimeSpan.FromMinutes(10); t.RefreshTokenLifetime = TimeSpan.FromDays(7); - c.IdleBuffer = TimeSpan.FromMinutes(5); + 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) @@ -194,7 +110,8 @@ private static void ApplySemiHybridDefaults(UAuthServerOptions o) t.RefreshTokenLifetime = TimeSpan.FromDays(7); t.AddJwtIdClaim = true; - c.IdleBuffer = TimeSpan.FromMinutes(5); + c.AccessToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); + c.RefreshToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); } private static void ApplyPureJwtDefaults(UAuthServerOptions o) @@ -216,7 +133,8 @@ private static void ApplyPureJwtDefaults(UAuthServerOptions o) t.RefreshTokenLifetime = TimeSpan.FromDays(7); t.AddJwtIdClaim = true; - c.IdleBuffer = TimeSpan.FromSeconds(30); + 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..05a20b9e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +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); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs index 7db1913d..e4968027 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs @@ -13,6 +13,16 @@ public sealed class LoginRedirectOptions public string CodeQueryKey { get; set; } = "code"; public Dictionary FailureCodes { get; set; } = new(); - } + internal LoginRedirectOptions Clone() => new() + { + RedirectEnabled = RedirectEnabled, + SuccessRedirect = SuccessRedirect, + FailureRedirect = FailureRedirect, + FailureQueryKey = FailureQueryKey, + CodeQueryKey = CodeQueryKey, + FailureCodes = new Dictionary(FailureCodes) + }; + + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs index 43756404..90ea5e2f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs @@ -16,6 +16,13 @@ public sealed class LogoutRedirectOptions /// 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/PrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs index d28e7ffe..77dd2055 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs @@ -13,5 +13,12 @@ public sealed class PrimaryCredentialPolicy /// Default primary credential for API requests. /// public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; + + internal PrimaryCredentialPolicy Clone() => new() + { + Ui = Ui, + Api = Api + }; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs new file mode 100644 index 00000000..5a1af3fe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs @@ -0,0 +1,25 @@ +using System.Xml.Linq; + +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 index d0eb9b08..cd0af2ed 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs @@ -4,22 +4,21 @@ namespace CodeBeam.UltimateAuth.Server.Options; public sealed class UAuthCookieOptions { - public string Name { get; set; } = "uas"; + public string Name { get; set; } = default!; - /// - /// Controls whether the cookie is inaccessible to JavaScript. - /// Default: true (recommended). - /// public bool HttpOnly { get; set; } = true; // TODO: Add UAUTH002 diagnostic if false? public CookieSecurePolicy SecurePolicy { get; set; } = CookieSecurePolicy.Always; - internal SameSiteMode? SameSiteOverride { get; set; } + public SameSiteMode? SameSite { get; set; } + + public string Path { get; set; } = "/"; /// - /// Cookie path. Default is "/". + /// Optional cookie domain. + /// Use with caution. Null means host-only cookie. /// - public string Path { get; set; } = "/"; + public string? Domain { get; set; } /// /// If set, defines absolute expiration for the cookie. @@ -27,10 +26,16 @@ public sealed class UAuthCookieOptions /// public TimeSpan? MaxAge { get; set; } - /// - /// Additional tolerance added to session idle timeout - /// when resolving cookie lifetime. - /// Default: 5 minutes. - /// - public TimeSpan IdleBuffer { get; set; } = TimeSpan.FromMinutes(5); + 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/UAuthCookiePolicyResolver.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyResolver.cs deleted file mode 100644 index ce11dc99..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyResolver.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Options; - -internal static class UAuthCookiePolicyResolver -{ - public static SameSiteMode ResolveSameSite(UAuthServerOptions options) - { - if (options.Cookie.SameSiteOverride is not null) - return options.Cookie.SameSiteOverride.Value; - - return options.HubDeploymentMode switch - { - UAuthHubDeploymentMode.Embedded => SameSiteMode.Strict, - UAuthHubDeploymentMode.Integrated => SameSiteMode.Lax, - UAuthHubDeploymentMode.External => SameSiteMode.None, - _ => throw new InvalidOperationException() - }; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs new file mode 100644 index 00000000..b0f73628 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class UAuthCookieSetOptions + { + public bool EnableSessionCookie { get; set; } = true; + public bool EnableAccessTokenCookie { get; set; } = true; + public bool EnableRefreshTokenCookie { get; set; } = true; + + public UAuthCookieOptions Session { get; init; } = new() + { + Name = "uas", + HttpOnly = true, + SameSite = SameSiteMode.None + }; + + public UAuthCookieOptions RefreshToken { get; init; } = new() + { + Name = "uar", + HttpOnly = true, + SameSite = SameSiteMode.None + }; + + public UAuthCookieOptions AccessToken { get; init; } = new() + { + Name = "uat", + HttpOnly = true, + SameSite = SameSiteMode.None + }; + + internal UAuthCookieSetOptions 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 index 08a8f754..20a83ec3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Server.Options +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options { public sealed class UAuthDiagnosticsOptions { @@ -7,5 +9,11 @@ public sealed class UAuthDiagnosticsOptions /// Should be disabled in production. /// public bool EnableRefreshHeaders { get; set; } = false; + + internal UAuthDiagnosticsOptions Clone() => new() + { + EnableRefreshHeaders = EnableRefreshHeaders + }; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs new file mode 100644 index 00000000..047ce87f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class UAuthHubOptions + { + public string? ClientBaseAddress { get; set; } + + public HashSet AllowedClientOrigins { get; set; } = new(); + + internal UAuthHubOptions Clone() => new() + { + ClientBaseAddress = ClientBaseAddress, + AllowedClientOrigins = new HashSet(AllowedClientOrigins) + }; + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 78218f99..be3422fb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -71,7 +71,7 @@ public sealed class UAuthServerOptions /// Allows advanced users to override cookie behavior. /// Unsafe combinations will be rejected at startup. /// - public UAuthCookieOptions Cookie { get; } = new(); + public UAuthCookieSetOptions Cookie { get; set; } = new(); public UAuthDiagnosticsOptions Diagnostics { get; set; } = new(); @@ -90,11 +90,13 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public AuthResponseOptions AuthResponse { 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; } = new(); + public UAuthSessionResolutionOptions SessionResolution { get; set; } = new(); /// /// Enables/disables specific endpoint groups. @@ -133,5 +135,44 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage /// Example: overriding default ILoginService. /// public Action? ConfigureServices { get; set; } + + + internal Dictionary> ModeConfigurations { get; } = new(); + + + internal UAuthServerOptions Clone() + { + return new UAuthServerOptions + { + Mode = Mode, + HubDeploymentMode = HubDeploymentMode, + RoutePrefix = RoutePrefix, + + Session = Session.Clone(), + Tokens = Tokens.Clone(), + Pkce = Pkce.Clone(), + MultiTenant = MultiTenant.Clone(), + Cookie = Cookie.Clone(), + Diagnostics = Diagnostics.Clone(), + + PrimaryCredential = PrimaryCredential.Clone(), + AuthResponse = AuthResponse.Clone(), + Hub = Hub.Clone(), + SessionResolution = SessionResolution.Clone(), + + EnableLoginEndpoints = EnableLoginEndpoints, + EnablePkceEndpoints = EnablePkceEndpoints, + EnableTokenEndpoints = EnableTokenEndpoints, + EnableSessionEndpoints = EnableSessionEndpoints, + EnableUserInfoEndpoints = EnableUserInfoEndpoints, + + EnableAntiCsrfProtection = EnableAntiCsrfProtection, + EnableLoginRateLimiting = EnableLoginRateLimiting, + + OnConfigureEndpoints = OnConfigureEndpoints, + ConfigureServices = ConfigureServices, + CustomCookieManagerType = CustomCookieManagerType + }; + } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs index 9bb49a5f..1b77131c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs @@ -3,8 +3,7 @@ namespace CodeBeam.UltimateAuth.Server.Options { - public sealed class UAuthServerOptionsValidator - : IValidateOptions + public sealed class UAuthServerOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate( string? name, @@ -24,7 +23,7 @@ public ValidateOptionsResult Validate( // ------------------------- // AUTH MODE VALIDATION // ------------------------- - if (!Enum.IsDefined(typeof(UAuthMode), options.Mode)) + if (options.Mode.HasValue && !Enum.IsDefined(typeof(UAuthMode), options.Mode)) { return ValidateOptionsResult.Fail( $"Invalid UAuthMode: {options.Mode}"); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs index fcd717c9..8eedf8a6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs @@ -19,8 +19,8 @@ public UAuthClientProfile Detect(IServiceProvider sp) return UAuthClientProfile.BlazorServer; } - if (sp.GetService() is not null) - return UAuthClientProfile.Mvc; + //if (sp.GetService() is not null) + // return UAuthClientProfile.WebServer; return UAuthClientProfile.NotSpecified; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs index 2370aebd..37881c5a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs @@ -21,5 +21,17 @@ public sealed class UAuthSessionResolutionOptions "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/Services/IRefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs new file mode 100644 index 00000000..32bf604c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs @@ -0,0 +1,10 @@ +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.Core/Abstractions/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs index 748032ad..2888ae2e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Server.Services { /// /// Handles authentication flows such as login, @@ -8,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// public interface IUAuthFlowService { - Task LoginAsync(LoginRequest request, CancellationToken ct = default); + Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default); Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); @@ -20,7 +21,7 @@ public interface IUAuthFlowService Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default); - Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default); + Task RefreshSessionAsync(AuthFlowContext flow, SessionRefreshRequest request, CancellationToken ct = default); Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs similarity index 52% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs index bca0bd6c..c21ffa68 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Server.Services { /// /// Issues, refreshes and validates access and refresh tokens. @@ -12,16 +13,16 @@ public interface IUAuthTokenService /// Issues access (and optionally refresh) tokens /// for a validated session. /// - Task CreateTokensAsync(TokenIssueContext context, CancellationToken cancellationToken = default); + Task CreateTokensAsync(AuthFlowContext flow, TokenIssueContext context, CancellationToken cancellationToken = default); /// /// Refreshes tokens using a refresh token. /// - Task RefreshAsync(TokenRefreshContext context, CancellationToken cancellationToken = default); + Task RefreshAsync(AuthFlowContext flow, TokenRefreshContext context, CancellationToken cancellationToken = default); /// - /// Validates an access token (JWT or opaque). + /// Validates JWT. /// - Task> ValidateAsync(string token, TokenType type, CancellationToken cancellationToken = default); + Task> ValidateJwtAsync(string token, CancellationToken cancellationToken = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs new file mode 100644 index 00000000..6435f9ac --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -0,0 +1,72 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +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 IRefreshTokenStore _store; + private readonly ITokenIssuer _tokenIssuer; + private readonly IClock _clock; + + public RefreshTokenRotationService( + IRefreshTokenValidator validator, + IRefreshTokenStore store, + ITokenIssuer tokenIssuer, + IClock clock) + { + _validator = validator; + _store = store; + _tokenIssuer = tokenIssuer; + _clock = clock; + } + + public async Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default) + { + var now = context.Now; + var validation = await _validator.ValidateAsync( + flow.TenantId, + context.RefreshToken, + now, + ct); + + // ❌ Invalid + if (!validation.IsValid) + return RefreshTokenRotationResult.Failed(); + + // 🚨 Reuse detected → nuke from orbit + if (validation.IsReuseDetected) + { + if (validation.ChainId is not null) + { + await _store.RevokeByChainAsync(validation.TenantId, validation.ChainId.Value, now, ct); + } + else if (validation.SessionId is not null) + { + await _store.RevokeBySessionAsync(validation.TenantId, validation.SessionId.Value, now, ct); + } + + return RefreshTokenRotationResult.Failed(); + } + + + // ✅ Valid rotation + var tokenContext = new TokenIssuanceContext + { + TenantId = flow.OriginalOptions.MultiTenant.Enabled + ? validation.TenantId + : null, + + UserId = validation.UserId!.ToString()!, + SessionId = validation.SessionId!.Value + }; + + var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); + var refreshToken = await _tokenIssuer.IssueRefreshTokenAsync(flow, tokenContext, ct); + + return RefreshTokenRotationResult.Success(accessToken, refreshToken!); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 834067b0..f1fcee8e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -1,6 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; using Microsoft.AspNetCore.Http; @@ -14,23 +18,20 @@ internal sealed class UAuthFlowService : IUAuthFlowService private readonly ISessionOrchestrator _orchestrator; private readonly ISessionQueryService _queries; private readonly ITokenIssuer _tokens; - private readonly ITokenStore _tokenStore; - private readonly IRefreshTokenResolver _refreshTokens; + private readonly IRefreshTokenValidator _tokenValidator; public UAuthFlowService( IUAuthUserService users, ISessionOrchestrator orchestrator, ISessionQueryService queries, ITokenIssuer tokens, - ITokenStore tokenStore, - IRefreshTokenResolver refreshTokens) + IRefreshTokenValidator tokenValidator) { _users = users; _orchestrator = orchestrator; _queries = queries; _tokens = tokens; - _tokenStore = tokenStore; - _refreshTokens = refreshTokens; + _tokenValidator = tokenValidator; } public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) @@ -58,18 +59,16 @@ public Task ExternalLoginAsync(ExternalLoginRequest request, Cancel throw new NotImplementedException(); } - public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) + public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) { var now = request.At ?? DateTimeOffset.UtcNow; var device = request.DeviceInfo ?? DeviceInfo.Unknown; - // 1️⃣ Authenticate user (NO session yet) var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); if (!auth.Succeeded) return LoginResult.Failed(); - // 2️⃣ Create authenticated context var sessionContext = new AuthenticatedSessionContext { TenantId = request.TenantId, @@ -86,34 +85,24 @@ public async Task LoginAsync(LoginRequest request, CancellationToke now, DeviceContext.From(device)); - // 3️⃣ Issue session THROUGH orchestrator - var issuedSession = await _orchestrator.ExecuteAsync( - authContext, - new CreateLoginSessionCommand(sessionContext), - ct); + var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); + + bool shouldIssueTokens = request.RequestTokens; - // 4️⃣ Optional tokens AuthTokens? tokens = null; - if (request.RequestTokens) + if (shouldIssueTokens) { - var access = await _tokens.IssueAccessTokenAsync( - new TokenIssuanceContext - { - TenantId = request.TenantId, - UserId = auth.UserId!.ToString()!, - SessionId = issuedSession.Session.SessionId - }, - ct); + var tokenContext = new TokenIssuanceContext + { + TenantId = request.TenantId, + UserId = auth.UserId!.ToString()!, + SessionId = issuedSession.Session.SessionId, + Claims = auth.Claims.AsDictionary() + }; - var refresh = await _tokens.IssueRefreshTokenAsync( - new TokenIssuanceContext - { - TenantId = request.TenantId, - UserId = auth.UserId!.ToString()!, - SessionId = issuedSession.Session.SessionId - }, - ct); + var access = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); + var refresh = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, ct); tokens = new AuthTokens { AccessToken = access, RefreshToken = refresh }; } @@ -165,17 +154,8 @@ public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct throw new InvalidOperationException("Current session chain could not be resolved."); } - var authContext = AuthContext.System( - request.TenantId, - AuthOperation.Revoke, - now); - - await _orchestrator.ExecuteAsync( - authContext, - new RevokeAllChainsCommand( - userId, - exceptChainId), - ct); + var authContext = AuthContext.System(request.TenantId, AuthOperation.Revoke, now); + await _orchestrator.ExecuteAsync(authContext, new RevokeAllChainsCommand(userId, exceptChainId), ct); } public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) @@ -183,15 +163,12 @@ public Task ReauthenticateAsync(ReauthRequest request, Cancellatio throw new NotImplementedException(); } - public async Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default) + public async Task RefreshSessionAsync(AuthFlowContext flow, SessionRefreshRequest request, CancellationToken ct = default) { var now = DateTimeOffset.UtcNow; // Validate refresh token (STORE is authority) - var validation = await _tokenStore.ValidateRefreshTokenAsync( - request.TenantId, - request.RefreshToken, - now); + var validation = await _tokenValidator.ValidateAsync(flow.TenantId, request.RefreshToken, now, ct); if (!validation.IsValid) { @@ -246,8 +223,8 @@ await _orchestrator.ExecuteAsync( SessionId = issuedSession.Session.SessionId }; - var accessToken = await _tokens.IssueAccessTokenAsync(tokenContext, ct); - var refreshToken = await _tokens.IssueRefreshTokenAsync(tokenContext, ct); + var accessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); + var refreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, ct); var primaryToken = PrimaryToken.FromAccessToken(accessToken); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs new file mode 100644 index 00000000..5848519f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs @@ -0,0 +1,85 @@ +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; + +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 (!string.IsNullOrWhiteSpace(sid)) + { + sessionId = new AuthSessionId(sid); + } + + return TokenValidationResult.Valid( + type: TokenType.Jwt, + tenantId: 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/UAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs index b3781d71..544de760 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs @@ -1,17 +1,17 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Auth; namespace CodeBeam.UltimateAuth.Server.Services { internal sealed class UAuthTokenService : IUAuthTokenService { private readonly ITokenIssuer _issuer; - private readonly ITokenValidator _validator; + private readonly IJwtValidator _validator; private readonly IUserIdConverter _userIdConverter; - public UAuthTokenService(ITokenIssuer issuer, ITokenValidator validator, IUserIdConverterResolver converterResolver) + public UAuthTokenService(ITokenIssuer issuer, IJwtValidator validator, IUserIdConverterResolver converterResolver) { _issuer = issuer; _validator = validator; @@ -19,13 +19,14 @@ public UAuthTokenService(ITokenIssuer issuer, ITokenValidator validator, IUserId } public async Task CreateTokensAsync( + AuthFlowContext flow, TokenIssueContext context, CancellationToken ct = default) { var issuerCtx = ToIssuerContext(context); - var access = await _issuer.IssueAccessTokenAsync(issuerCtx, ct); - var refresh = await _issuer.IssueRefreshTokenAsync(issuerCtx, ct); + var access = await _issuer.IssueAccessTokenAsync(flow, issuerCtx, ct); + var refresh = await _issuer.IssueRefreshTokenAsync(flow, issuerCtx, ct); return new AuthTokens { @@ -35,17 +36,15 @@ public async Task CreateTokensAsync( } public async Task RefreshAsync( + AuthFlowContext flow, TokenRefreshContext context, CancellationToken ct = default) { throw new NotImplementedException("Refresh flow will be implemented after refresh-token store & validation."); } - public async Task> ValidateAsync( - string token, - TokenType type, - CancellationToken ct = default) - => await _validator.ValidateAsync(token, type, ct); + public async Task> ValidateJwtAsync(string token, CancellationToken ct = default) + => await _validator.ValidateAsync(token, ct); private TokenIssuanceContext ToIssuerContext(TokenIssueContext src) { diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs deleted file mode 100644 index d48cba0f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs +++ /dev/null @@ -1,165 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using System.Security.Claims; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - internal sealed class UAuthTokenValidator : ITokenValidator - { - private readonly IOpaqueTokenStore _opaqueStore; - private readonly JsonWebTokenHandler _jwtHandler; - private readonly TokenValidationParameters _jwtParameters; - private readonly IUserIdConverterResolver _converters; - private readonly UAuthServerOptions _options; - private readonly ITokenHasher _tokenHasher; - - public UAuthTokenValidator( - IOpaqueTokenStore opaqueStore, - TokenValidationParameters jwtParameters, - IUserIdConverterResolver converters, - IOptions options, - ITokenHasher tokenHasher) - { - _opaqueStore = opaqueStore; - _jwtHandler = new JsonWebTokenHandler(); - _jwtParameters = jwtParameters; - _converters = converters; - _options = options.Value; - _tokenHasher = tokenHasher; - } - - public async Task> ValidateAsync( - string token, - TokenType type, - CancellationToken ct = default) - { - return type switch - { - TokenType.Jwt => await ValidateJwt(token), - TokenType.Opaque => await ValidateOpaqueAsync(token, ct), - _ => TokenValidationResult.Invalid(TokenType.Unknown, TokenInvalidReason.Unknown) - }; - } - - // ---------------- JWT ---------------- - - private async Task> ValidateJwt(string token) - { - 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 (!string.IsNullOrWhiteSpace(sid)) - { - sessionId = new AuthSessionId(sid); - } - - return TokenValidationResult.Valid( - type: TokenType.Jwt, - tenantId: tenantId, - userId, - sessionId: sessionId, - claims: claims, - expiresAt: jwt.ValidTo); - } - - - // ---------------- OPAQUE ---------------- - - private async Task> ValidateOpaqueAsync(string token, CancellationToken ct) - { - var hash = _tokenHasher.Hash(token); - - var record = await _opaqueStore.FindByHashAsync(hash, ct); - if (record is null) - { - return TokenValidationResult.Invalid( - TokenType.Opaque, - TokenInvalidReason.Invalid); - } - - var now = DateTimeOffset.UtcNow; - if (record.ExpiresAt <= now) - { - return TokenValidationResult.Invalid( - TokenType.Opaque, - TokenInvalidReason.Expired); - } - - if (record.IsRevoked) - { - return TokenValidationResult.Invalid( - TokenType.Opaque, - TokenInvalidReason.Revoked); - } - - var converter = _converters.GetConverter(); - - TUserId userId; - try - { - userId = converter.FromString(record.UserId); - } - catch - { - return TokenValidationResult.Invalid( - TokenType.Opaque, - TokenInvalidReason.Invalid); - } - - return TokenValidationResult.Valid( - TokenType.Opaque, - record.TenantId, - userId, - record.SessionId, - record.Claims, - record.ExpiresAt.UtcDateTime); - } - - 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/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs index e20fa105..3753ad00 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs @@ -1,174 +1,128 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; -namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; - -internal sealed class EfCoreTokenStore : ITokenStore +internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore { private readonly UltimateAuthTokenDbContext _db; - private readonly EfCoreTokenStoreKernel _kernel; - private readonly ISessionStore _sessions; - private readonly ITokenHasher _hasher; + private readonly IUserIdConverter _converter; - public EfCoreTokenStore( + public EfCoreRefreshTokenStore( UltimateAuthTokenDbContext db, - EfCoreTokenStoreKernel kernel, - ISessionStore sessions, - ITokenHasher hasher) + IUserIdConverterResolver converters) { _db = db; - _kernel = kernel; - _sessions = sessions; - _hasher = hasher; - } - - public Task StoreRefreshTokenAsync(string? tenantId, TUserId userId, AuthSessionId sessionId, string refreshTokenHash, DateTimeOffset expiresAt) - { - return _kernel.ExecuteAsync(ct => - { - _db.RefreshTokens.Add(new RefreshTokenProjection - { - TenantId = tenantId, - TokenHash = refreshTokenHash, - SessionId = sessionId, - ExpiresAt = expiresAt - }); - - return Task.CompletedTask; - }); + _converter = converters.GetConverter(); } - public async Task> ValidateRefreshTokenAsync(string? tenantId, string providedRefreshToken, DateTimeOffset now) + public async Task StoreAsync( + string? tenantId, + StoredRefreshToken token, + CancellationToken ct = default) { - var hash = _hasher.Hash(providedRefreshToken); + if (token.TenantId != tenantId) + throw new InvalidOperationException("TenantId mismatch between context and token."); - return await _kernel.ExecuteAsync(async ct => + _db.RefreshTokens.Add(new RefreshTokenProjection { - var token = await _db.RefreshTokens - .SingleOrDefaultAsync( - x => x.TokenHash == hash && - x.TenantId == tenantId, - ct); - - if (token is null) - return RefreshTokenValidationResult.Invalid(); - - if (token.RevokedAt != null) - return RefreshTokenValidationResult.ReuseDetected(); - - if (token.ExpiresAt <= now) - { - token.RevokedAt = now; - return RefreshTokenValidationResult.Invalid(); - } - - // Revoke on first use (rotation) - token.RevokedAt = now; - - var session = await _sessions.GetSessionAsync( - tenantId, - token.SessionId, - ct); - - if (session is null || - session.IsRevoked || - session.ExpiresAt <= now) - { - return RefreshTokenValidationResult.Invalid(); - } - - return RefreshTokenValidationResult.Valid( - session.UserId, - session.SessionId); + TenantId = tenantId, + TokenHash = token.TokenHash, + UserId = _converter.ToString(token.UserId), + SessionId = token.SessionId.Value, + ChainId = token.ChainId.Value, + IssuedAt = token.IssuedAt, + ExpiresAt = token.ExpiresAt }); - } - public Task RevokeRefreshTokenAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) - { - return _kernel.ExecuteAsync(async ct => - { - var tokens = await _db.RefreshTokens - .Where(x => - x.TenantId == tenantId && - x.SessionId == sessionId && - x.RevokedAt == null) - .ToListAsync(ct); - - foreach (var token in tokens) - token.RevokedAt = at; - }); + await _db.SaveChangesAsync(ct); } - public Task RevokeAllRefreshTokensAsync(string? tenantId, TUserId _, DateTimeOffset at) + public async Task?> FindByHashAsync( + string? tenantId, + string tokenHash, + CancellationToken ct = default) { - return _kernel.ExecuteAsync(async ct => - { - var tokens = await _db.RefreshTokens - .Where(x => - x.TenantId == tenantId && - x.RevokedAt == null) - .ToListAsync(ct); - - foreach (var token in tokens) - token.RevokedAt = at; - }); - } + var e = await _db.RefreshTokens + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.TokenHash == tokenHash && + x.TenantId == tenantId, + ct); - // ------------------------------------------------------------ - // JWT ID (JTI) - // ------------------------------------------------------------ + if (e is null) + return null; - public Task StoreTokenIdAsync(string? tenantId, string jti, DateTimeOffset expiresAt) - { - return _kernel.ExecuteAsync(ct => + return new StoredRefreshToken { - _db.RevokedTokenIds.Add(new RevokedTokenIdProjection - { - TenantId = tenantId, - Jti = jti, - ExpiresAt = expiresAt, - RevokedAt = expiresAt - }); - - return Task.CompletedTask; - }); + TenantId = e.TenantId, + TokenHash = e.TokenHash, + UserId = _converter.FromString(e.UserId), + SessionId = new AuthSessionId(e.SessionId), + ChainId = new ChainId(e.ChainId), + IssuedAt = e.IssuedAt, + ExpiresAt = e.ExpiresAt, + RevokedAt = e.RevokedAt + }; } - public async Task IsTokenIdRevokedAsync(string? tenantId, string jti) - { - return await _db.RevokedTokenIds - .AsNoTracking() - .AnyAsync(x => - x.Jti == jti && - x.TenantId == tenantId); - } + public Task RevokeAsync( + string? tenantId, + string tokenHash, + DateTimeOffset revokedAt, + CancellationToken ct = default) + => _db.RefreshTokens + .Where(x => + x.TokenHash == tokenHash && + x.TenantId == tenantId && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + + public Task RevokeBySessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + => _db.RefreshTokens + .Where(x => + x.TenantId == tenantId && + x.SessionId == sessionId.Value && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); - public Task RevokeTokenIdAsync(string? tenantId, string jti, DateTimeOffset at) - { - return _kernel.ExecuteAsync(async ct => - { - var record = await _db.RevokedTokenIds - .SingleOrDefaultAsync( - x => x.Jti == jti && - x.TenantId == tenantId, - ct); + public Task RevokeByChainAsync( + string? tenantId, + ChainId chainId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + => _db.RefreshTokens + .Where(x => + x.TenantId == tenantId && + x.ChainId == chainId.Value && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); - if (record is null) - { - _db.RevokedTokenIds.Add(new RevokedTokenIdProjection - { - TenantId = tenantId, - Jti = jti, - ExpiresAt = at, - RevokedAt = at - }); - } - else - { - record.RevokedAt = at; - } - }); + public Task RevokeAllForUserAsync( + string? tenantId, + TUserId userId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + var uid = _converter.ToString(userId); + + return _db.RefreshTokens + .Where(x => + x.TenantId == tenantId && + x.UserId == uid && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs deleted file mode 100644 index 0abe987f..00000000 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System.Data; - -namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; - -internal sealed class EfCoreTokenStoreKernel -{ - private readonly UltimateAuthTokenDbContext _db; - - public EfCoreTokenStoreKernel(UltimateAuthTokenDbContext db) - { - _db = db; - } - - public async Task ExecuteAsync(Func action, CancellationToken ct = default) - { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted,ct); - - _db.Database.UseTransaction(tx); - - try - { - await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - finally - { - _db.Database.UseTransaction(null); - } - } - - public async Task ExecuteAsync(Func> action,CancellationToken ct = default) - { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); - - _db.Database.UseTransaction(tx); - - try - { - var result = await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - return result; - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - finally - { - _db.Database.UseTransaction(null); - } - } -} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index c3ed93d5..def05c6a 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -9,12 +9,13 @@ internal sealed class RefreshTokenProjection public string? TenantId { get; set; } public string TokenHash { get; set; } = default!; - public AuthSessionId SessionId { get; set; } + public string UserId { get; set; } = default!; + public string SessionId { get; set; } = default!; + public ChainId ChainId { get; set; } = default!; + public DateTimeOffset IssuedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset? RevokedAt { get; set; } public byte[] RowVersion { get; set; } = default!; - - public bool IsRevoked => RevokedAt != null; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs index 4fff2ab7..9dc2fe31 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -9,8 +9,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) { services.AddDbContext(configureDb); - services.AddScoped(); - services.AddScoped(typeof(ITokenStore<>), typeof(EfCoreTokenStore<>)); + services.AddScoped(typeof(IRefreshTokenStore<>), typeof(EfCoreRefreshTokenStore<>)); return services; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs index d3cb5e3b..59f13471 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -28,19 +28,14 @@ protected override void OnModelCreating(ModelBuilder b) e.Property(x => x.TokenHash) .IsRequired(); - e.Property(x => x.SessionId) - .HasConversion( - v => v.Value, - v => new AuthSessionId(v)) - .IsRequired(); - - e.HasIndex(x => x.TokenHash) + e.HasIndex(x => new { x.TenantId, x.TokenHash }) .IsUnique(); + e.HasIndex(x => new { x.TenantId, x.UserId }); e.HasIndex(x => new { x.TenantId, x.SessionId }); + e.HasIndex(x => new { x.TenantId, x.ChainId }); - e.Property(x => x.ExpiresAt) - .IsRequired(); + e.Property(x => x.ExpiresAt).IsRequired(); }); // ------------------------------------------------- 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..a4744064 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore +{ + private static string NormalizeTenant(string? tenantId) + => tenantId ?? "__default__"; + + private readonly ConcurrentDictionary> _tokens + = new(); + + public Task StoreAsync( + string? tenantId, + StoredRefreshToken token, + CancellationToken ct = default) + { + var key = new TokenKey( + NormalizeTenant(tenantId), + token.TokenHash); + + _tokens[key] = token; + return Task.CompletedTask; + } + + public Task?> FindByHashAsync( + string? tenantId, + string tokenHash, + CancellationToken ct = default) + { + var key = new TokenKey( + NormalizeTenant(tenantId), + tokenHash); + + _tokens.TryGetValue(key, out var token); + return Task.FromResult(token); + } + + public Task RevokeAsync( + string? tenantId, + string tokenHash, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + var key = new TokenKey( + NormalizeTenant(tenantId), + tokenHash); + + if (_tokens.TryGetValue(key, out var token) && !token.IsRevoked) + { + _tokens[key] = token with { RevokedAt = revokedAt }; + } + + return Task.CompletedTask; + } + + public Task RevokeBySessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + var tenant = NormalizeTenant(tenantId); + + foreach (var (key, token) in _tokens) + { + if (key.TenantId == tenant && + token.SessionId == sessionId && + !token.IsRevoked) + { + _tokens[key] = token with { RevokedAt = revokedAt }; + } + } + + return Task.CompletedTask; + } + + public Task RevokeByChainAsync( + string? tenantId, + ChainId chainId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + var tenant = NormalizeTenant(tenantId); + + foreach (var (key, token) in _tokens) + { + if (key.TenantId == tenant && + token.ChainId == chainId && + !token.IsRevoked) + { + _tokens[key] = token with { RevokedAt = revokedAt }; + } + } + + return Task.CompletedTask; + } + + public Task RevokeAllForUserAsync( + string? tenantId, + TUserId userId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + var tenant = NormalizeTenant(tenantId); + + foreach (var (key, token) in _tokens) + { + if (key.TenantId == tenant && + EqualityComparer.Default.Equals(token.UserId, userId) && + !token.IsRevoked) + { + _tokens[key] = token with { RevokedAt = revokedAt }; + } + } + + return Task.CompletedTask; + } + + private readonly record struct TokenKey(string TenantId, string TokenHash); +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs deleted file mode 100644 index 331bdf1d..00000000 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs +++ /dev/null @@ -1,96 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Tokens.InMemory; - -internal sealed class InMemoryTokenStore : ITokenStore -{ - private readonly ITokenStoreFactory _factory; - private readonly ISessionStoreFactory _sessions; - private readonly ITokenHasher _hasher; - - public InMemoryTokenStore(ITokenStoreFactory factory, ISessionStoreFactory sessions, ITokenHasher hasher) - { - _factory = factory; - _sessions = sessions; - _hasher = hasher; - } - - public async Task StoreRefreshTokenAsync(string? tenantId, TUserId userId, AuthSessionId sessionId, string refreshTokenHash, DateTimeOffset expiresAt) - { - var kernel = _factory.Create(tenantId); - - var stored = new StoredRefreshToken - { - TokenHash = refreshTokenHash, - SessionId = sessionId, - ExpiresAt = expiresAt - }; - - await kernel.SaveRefreshTokenAsync(tenantId, stored); - } - - public async Task> ValidateRefreshTokenAsync(string? tenantId, string providedRefreshToken, DateTimeOffset now) - { - var kernel = _factory.Create(tenantId); - - var hash = _hasher.Hash(providedRefreshToken); - var stored = await kernel.GetRefreshTokenAsync(tenantId, hash); - - if (stored is null) - return RefreshTokenValidationResult.Invalid(); - - if (stored.IsRevoked) - return RefreshTokenValidationResult.ReuseDetected(); - - if (stored.ExpiresAt <= now) - { - await kernel.RevokeRefreshTokenAsync(tenantId, hash, now); - return RefreshTokenValidationResult.Invalid(); - } - - await kernel.RevokeRefreshTokenAsync(tenantId, hash, now); - - var sessionKernel = _sessions.Create(tenantId); - var session = await sessionKernel.GetSessionAsync(tenantId, stored.SessionId); - - if (session is null || session.IsRevoked || session.ExpiresAt <= now) - return RefreshTokenValidationResult.Invalid(); - - return RefreshTokenValidationResult.Valid( - session.UserId, - session.SessionId); - } - - public Task RevokeRefreshTokenAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) - { - var kernel = _factory.Create(tenantId); - return kernel.RevokeAllRefreshTokensAsync(tenantId, null, at); - } - - public Task RevokeAllRefreshTokensAsync(string? tenantId, TUserId _, DateTimeOffset at) - { - var kernel = _factory.Create(tenantId); - return kernel.RevokeAllRefreshTokensAsync(tenantId, null, at); - } - - - public Task StoreTokenIdAsync(string? tenantId, string jti, DateTimeOffset expiresAt) - { - var kernel = _factory.Create(tenantId); - return kernel.StoreTokenIdAsync(tenantId, jti, expiresAt); - } - - public Task IsTokenIdRevokedAsync(string? tenantId, string jti) - { - var kernel = _factory.Create(tenantId); - return kernel.IsTokenIdRevokedAsync(tenantId, jti); - } - - public Task RevokeTokenIdAsync(string? tenantId, string jti, DateTimeOffset at) - { - var kernel = _factory.Create(tenantId); - return kernel.RevokeTokenIdAsync(tenantId, jti, at); - } -} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreFactory.cs deleted file mode 100644 index 909464d3..00000000 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreFactory.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Concurrent; -using CodeBeam.UltimateAuth.Core.Abstractions; - -namespace CodeBeam.UltimateAuth.Tokens.InMemory; - -internal sealed class InMemoryTokenStoreFactory : ITokenStoreFactory -{ - private readonly ConcurrentDictionary _kernels = new(); - - public ITokenStoreKernel Create(string? tenantId) - { - var key = tenantId ?? "__single__"; - - return _kernels.GetOrAdd( - key, - _ => new InMemoryTokenStoreKernel()); - } -} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreKernel.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreKernel.cs deleted file mode 100644 index 3ce3dc63..00000000 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStoreKernel.cs +++ /dev/null @@ -1,77 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using System.Collections.Concurrent; - -namespace CodeBeam.UltimateAuth.Tokens.InMemory; - -internal sealed class InMemoryTokenStoreKernel : ITokenStoreKernel -{ - private readonly ConcurrentDictionary _refreshTokens = new(); - private readonly ConcurrentDictionary _revokedJtis = new(); - - public Task SaveRefreshTokenAsync(string? _, StoredRefreshToken token) - { - _refreshTokens[token.TokenHash] = token; - return Task.CompletedTask; - } - - public Task GetRefreshTokenAsync(string? _, string tokenHash) - { - _refreshTokens.TryGetValue(tokenHash, out var token); - return Task.FromResult(token); - } - - public Task RevokeRefreshTokenAsync(string? _, string tokenHash, DateTimeOffset at) - { - if (_refreshTokens.TryGetValue(tokenHash, out var token)) - { - _refreshTokens[tokenHash] = token with { RevokedAt = at }; - } - - return Task.CompletedTask; - } - - public Task RevokeAllRefreshTokensAsync(string? _, string? __, DateTimeOffset at) - { - foreach (var kvp in _refreshTokens) - { - _refreshTokens[kvp.Key] = kvp.Value with { RevokedAt = at }; - } - - return Task.CompletedTask; - } - - public Task DeleteExpiredRefreshTokensAsync(string? _, DateTimeOffset now) - { - var dict = (IDictionary)_refreshTokens; - - foreach (var kvp in dict.ToList()) - { - if (kvp.Value.ExpiresAt <= now) - { - dict.Remove(kvp.Key); - } - } - - return Task.CompletedTask; - } - - // ------------------------------------------------------------ - // JWT ID (JTI) - // ------------------------------------------------------------ - - public Task StoreTokenIdAsync(string? _, string jti, DateTimeOffset expiresAt) - { - _revokedJtis[jti] = expiresAt; - return Task.CompletedTask; - } - - public Task IsTokenIdRevokedAsync(string? _, string jti) - => Task.FromResult(_revokedJtis.ContainsKey(jti)); - - public Task RevokeTokenIdAsync(string? _, string jti, DateTimeOffset at) - { - _revokedJtis[jti] = at; - return Task.CompletedTask; - } -} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs index 6d8f88c4..869d09e5 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -7,8 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemoryTokens(this IServiceCollection services) { - services.AddSingleton(); - services.AddScoped(typeof(ITokenStore<>), typeof(InMemoryTokenStore<>)); + services.AddScoped(typeof(IRefreshTokenStore<>), typeof(InMemoryRefreshTokenStore<>)); return services; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs index bba4e93f..70b7269e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit { @@ -14,6 +15,11 @@ public FakeUAuthClient(params RefreshOutcome[] outcomes) _outcomes = new Queue(outcomes); } + public Task GetCurrentPrincipalAsync() + { + throw new NotImplementedException(); + } + public Task LoginAsync(LoginRequest request) { throw new NotImplementedException(); 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..02881234 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs @@ -0,0 +1,40 @@ +using System; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server +{ + public class EffectiveAuthModeResolverTests + { + private readonly DefaultEffectiveAuthModeResolver _resolver = new(); + + [Fact] + public void ConfiguredMode_Wins_Over_ClientProfile() + { + var mode = _resolver.Resolve( + configuredMode: UAuthMode.PureJwt, + clientProfile: UAuthClientProfile.BlazorWasm, + flowType: AuthFlowType.Login); + + Assert.Equal(UAuthMode.PureJwt, mode); + } + + [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( + configuredMode: null, + 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..d16e2ee0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs @@ -0,0 +1,146 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server +{ + public class EffectiveServerOptionsProviderTests + { + [Fact] + public void Original_Options_Are_Not_Mutated() + { + var baseOptions = new UAuthServerOptions + { + Mode = UAuthMode.Hybrid + }; + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); + + var effective = provider.GetEffective( + ctx, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + effective.Options.Tokens.AccessTokenLifetime = TimeSpan.FromSeconds(10); + + Assert.NotEqual( + baseOptions.Tokens.AccessTokenLifetime, + effective.Options.Tokens.AccessTokenLifetime + ); + } + + + [Fact] + public void EffectiveMode_Comes_From_ModeResolver() + { + var baseOptions = new UAuthServerOptions + { + Mode = null // Not specified + }; + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); + + var effective = provider.GetEffective( + ctx, + AuthFlowType.Login, + UAuthClientProfile.Api); + + Assert.Equal(UAuthMode.PureJwt, effective.Mode); + } + + [Fact] + public void Mode_Defaults_Are_Applied() + { + var baseOptions = new UAuthServerOptions + { + Mode = UAuthMode.PureOpaque + }; + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); + + 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_Defaults() + { + var baseOptions = new UAuthServerOptions + { + Mode = UAuthMode.Hybrid + }; + + baseOptions.ConfigureMode(UAuthMode.Hybrid, o => + { + o.Tokens.AccessTokenLifetime = TimeSpan.FromMinutes(1); + }); + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); + + var effective = provider.GetEffective( + ctx, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + Assert.Equal( + TimeSpan.FromMinutes(1), + effective.Options.Tokens.AccessTokenLifetime + ); + } + + [Fact] + public void Each_Call_Returns_New_EffectiveOptions_Instance() + { + var baseOptions = new UAuthServerOptions + { + Mode = UAuthMode.Hybrid + }; + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); + + 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/TestHelpers.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs new file mode 100644 index 00000000..46083ba0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit +{ + internal static class TestHelpers + { + public static DefaultEffectiveServerOptionsProvider CreateEffectiveOptionsProvider(UAuthServerOptions options, IEffectiveAuthModeResolver? modeResolver = null) + { + return new DefaultEffectiveServerOptionsProvider(Options.Create(options), modeResolver ?? new DefaultEffectiveAuthModeResolver()); + } + } +} From 286c7425006e0fd27c2b283c095e247bad238077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:25:07 +0300 Subject: [PATCH 22/50] Add Discord badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e247afcf..c8dd1aa1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ ![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) From 763c4eafe4c6c514302eb6917b9410963fb083a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:25:05 +0300 Subject: [PATCH 23/50] Preparation of First Release (Part 5/7) (#13) * Preparation of First Release (Part 5/7) * Add Complete Refresh Flow Especially For PureOpaque and Hybrid Auth Mode * Add PKCE for Login Flow * Add PKCE form login & Failed Behavior * PKCE Polish & Cleanup * Fix SessionId Cookie MaxAge on Hybrid Auth Mode * Add DeviceId Support & Big Refactoring * Minor Fixes * Fix Tests --- ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 3 +- .../Components/Layout/MainLayout.razor | 10 +- .../Components/Layout/MainLayout.razor.cs | 7 + .../Components/Layout/MainLayout.razor.css | 98 ------ .../Components/Pages/Counter.razor | 18 - .../Components/Pages/Home.razor | 67 +++- .../Components/Pages/Home.razor.cs | 133 ++++++++ .../Components/Routes.razor | 14 +- .../Components/_Imports.razor | 11 +- .../Controllers/HubLoginController.cs | 53 +++ .../DefaultUAuthHubMarker.cs | 5 + .../Program.cs | 21 +- .../Components/Pages/Home.razor | 8 +- .../Components/Pages/Home.razor.cs | 6 +- .../Components/Routes.razor | 6 +- .../App.razor | 6 +- .../Pages/Counter.razor | 18 - .../Pages/Home.razor | 11 +- .../Pages/Home.razor.cs | 22 +- .../Program.cs | 2 - .../Abstractions/IBrowserPostClient.cs | 6 +- .../Abstractions/IBrowserStorage.cs | 12 + .../DefaultUAuthStateManager.cs | 34 +- .../Authentication/IUAuthStateManager.cs | 2 +- .../UAuthAuthenticatonStateProvider.cs | 7 - .../UAuthCascadingStateProvider.cs | 26 ++ .../Authentication/UAuthState.cs | 12 +- .../CodeBeam.UltimateAuth.Client.csproj | 3 + .../Components/UALoginForm.razor | 12 + .../Components/UALoginForm.razor.cs | 123 ++++++- .../Components/UAuthAppRoot.razor | 18 + .../Components/UAuthAuthenticationState.razor | 13 + .../UAuthAuthenticationState.razor.cs | 46 +++ .../Components/UAuthClientProvider.razor | 4 + .../Components/UAuthClientProvider.razor.cs | 4 + .../Contracts/BrowserPostJsonResult.cs | 1 + .../Contracts/BrowserPostRawResult.cs | 12 + .../Contracts/PkceClientState.cs | 8 + .../Contracts/StorageScope.cs | 8 + .../Device/BrowserDeviceIdStorage.cs | 28 ++ .../Device/DefaultDeviceIdGenerator.cs | 17 + .../Device/DefaultDeviceIdProvider.cs | 38 +++ .../Device/IDeviceIdGenerator.cs | 9 + .../Device/IDeviceIdProvider.cs | 9 + .../Device/IDeviceIdStorage.cs | 8 + ...teAuthClientServiceCollectionExtensions.cs | 18 + .../Infrastructure/BrowserPostClient.cs | 40 ++- .../Infrastructure/BrowserStorage.cs | 30 ++ .../Infrastructure/BrowserUAuthBridge.cs | 18 + .../Infrastructure/IBrowserUAuthBridge.cs | 6 + .../Infrastructure/NoOpHubCapabilities.cs | 9 + .../NoOpHubCredentialResolver.cs | 10 + .../Infrastructure/NoOpHubFlowReader.cs | 10 + .../Options/PkceLoginOptions.cs | 27 ++ .../Options/UAuthClientOptions.cs | 24 +- .../Options/UAuthClientProfileDetector.cs | 4 + .../Runtime/IUAuthClientBootstrapper.cs | 9 + .../Runtime/UAuthClientBootstrapper.cs | 53 +++ .../Services/IUAuthClient.cs | 4 +- .../Services/UAuthClient.cs | 143 +++++++- .../wwwroot/uauth.js | 184 ++++++++--- .../Abstractions/Hub/IHubCapabilities.cs | 7 + .../Hub/IHubCredentialResolver.cs | 9 + .../Abstractions/Hub/IHubFlowReader.cs | 9 + .../Abstractions/Issuers/ISessionIssuer.cs | 12 +- .../Abstractions/Services/IUAuthService.cs | 2 +- .../Services/IUAuthSessionManager.cs | 27 ++ .../Services/IUAuthSessionService.cs | 33 -- .../Stores/DefaultSessionStoreFactory.cs | 23 +- .../Stores/IAccessTokenIdStore.cs | 17 +- .../Abstractions/Stores/IRefreshTokenStore.cs | 30 +- .../Stores/ISessionActivityWriter.cs | 9 - .../Abstractions/Stores/ISessionStore.cs | 16 +- .../Stores/ISessionStoreKernel.cs | 160 ++------- ...ctory.cs => ISessionStoreKernelFactory.cs} | 12 +- .../Stores/ITenantAwareSessionStore.cs | 7 + .../Abstractions/Stores/IUAuthUserStore.cs | 8 +- .../Validators/IRefreshTokenValidator.cs | 8 +- .../Contracts/Authority/AuthContext.cs | 52 +-- .../Authority/AuthenticationContext.cs | 4 +- .../Contracts/Authority/DeviceContext.cs | 28 -- .../Contracts/Authority/DeviceInfo.cs | 45 +++ .../Authority/SessionAccessContext.cs | 18 - .../Contracts/Login/LoginRequest.cs | 4 +- .../Contracts/Login/UAuthLoginType.cs | 8 + .../Contracts/Pkce/PkceAuthorizeResponse.cs | 7 + .../Contracts/Pkce/PkceChallengeResult.cs | 8 - .../Contracts/Pkce/PkceCompleteRequest.cs | 11 + .../Contracts/Pkce/PkceConsumeRequest.cs | 7 - .../Contracts/Pkce/PkceCreateRequest.cs | 7 - .../Contracts/Pkce/PkceLoginRequest.cs | 13 + .../Contracts/Pkce/PkceVerificationResult.cs | 7 - .../Contracts/Pkce/PkceVerifyRequest.cs | 8 - .../Contracts/Refresh/RefreshFlowRequest.cs | 13 + .../Contracts/Refresh/RefreshFlowResult.cs | 40 +++ .../Contracts/Refresh/RefreshStrategy.cs | 11 + .../Refresh/RefreshTokenPersistence.cs | 18 + .../Refresh/RefreshTokenValidationContext.cs | 15 + .../Contracts/Session/AuthStateSnapshot.cs | 1 + .../Session/AuthenticatedSessionContext.cs | 10 +- .../Contracts/Session/IssuedSession.cs | 4 +- .../Session/ResolvedRefreshSession.cs | 16 +- .../Contracts/Session/SessionRefreshResult.cs | 13 +- .../Contracts/Session/SessionResult.cs | 8 +- .../Session/SessionRotationContext.cs | 10 +- .../Session/SessionSecurityContext.cs | 18 + .../Contracts/Session/SessionStoreContext.cs | 8 +- .../Contracts/Session/SessionTouchMode.cs | 15 + .../Session/SessionValidationContext.cs | 2 +- .../Session/SessionValidationResult.cs | 92 +++--- .../Contracts/Token/PrimaryToken.cs | 2 +- .../Token/RefreshTokenRotationContext.cs | 8 +- .../Token/RefreshTokenRotationExecution.cs | 15 + .../Token/RefreshTokenRotationResult.cs | 6 +- .../Token/RefreshTokenValidationContext.cs | 12 - .../Token/RefreshTokenValidationResult.cs | 47 ++- .../Contracts/Token/TokenIssuanceContext.cs | 9 +- .../Contracts/Token/TokenIssueContext.cs | 4 +- .../Domain/Device/DeviceContext.cs | 27 ++ .../Domain/Device/DeviceId.cs | 69 ++++ .../Domain/Hub/HubCredentials.cs | 11 + .../Domain/Hub/HubFlowArtifact.cs | 33 ++ .../Domain/Hub/HubFlowPayload.cs | 22 ++ .../Domain/Hub/HubFlowState.cs | 18 + .../Domain/Hub/HubFlowType.cs | 13 + .../Domain/Hub/HubSessionId.cs | 23 ++ .../Domain/Pkce/AuthArtifact.cs | 34 ++ .../Domain/Pkce/AuthArtifactType.cs | 13 + .../Domain/Pkce/HubLoginArtifact.cs | 18 + .../Domain/Session/AuthSessionId.cs | 62 +--- .../Domain/Session/ChainId.cs | 68 ---- .../Domain/Session/DeviceInfo.cs | 106 ------ .../Domain/Session/ISession.cs | 18 +- .../Domain/Session/ISessionChain.cs | 14 +- .../Domain/Session/ISessionRoot.cs | 12 +- .../Domain/Session/SessionChainId.cs | 33 ++ .../Domain/Session/SessionRootId.cs | 26 ++ .../Domain/Session/UAuthSession.cs | 103 +++--- .../Domain/Session/UAuthSessionChain.cs | 74 +++-- .../Domain/Session/UAuthSessionRoot.cs | 62 ++-- .../Domain/Token/StoredRefreshToken.cs | 6 +- .../Domain/User/UserId.cs | 16 - .../Domain/User/UserKey.cs | 39 +++ .../Errors/Base/UAuthChainException.cs | 4 +- .../UAuthSessionChainNotFoundException.cs | 2 +- .../UAuthSessionChainRevokedException.cs | 4 +- .../UAuthSessionDeviceMismatchException.cs | 3 +- .../Events/SessionCreatedContext.cs | 4 +- .../Events/SessionRefreshedContext.cs | 4 +- .../Events/SessionRevokedContext.cs | 4 +- ...UltimateAuthServiceCollectionExtensions.cs | 2 +- .../UltimateAuthSessionStoreExtensions.cs | 182 +++++----- .../Authority/DeviceMismatchPolicy.cs | 32 ++ .../Authority/DevicePresenceInvariant.cs | 20 ++ .../Authority/DeviceTrustPolicy.cs | 32 -- .../DefaultRefreshTokenValidator.cs | 45 +-- .../Infrastructure/UAuthUserIdConverter.cs | 2 + .../Infrastructure/UserIdFactory.cs | 4 +- .../Options/UAuthClientProfile.cs | 3 +- .../Options/UAuthPkceOptions.cs | 5 +- .../Runtime/IUAuthHubMarker.cs | 10 + .../Abstractions/ICredentialResponseWriter.cs | 7 +- .../Abstractions/IDeviceResolver.cs | 2 +- .../Abstractions/IHttpSessionIssuer.cs | 6 +- .../Abstractions/ITokenIssuer.cs | 2 +- .../Abstractions/ResolvedCredential.cs | 3 +- .../DefaultAuthFlowContextAccessor.cs | 41 ++- .../Auth/Context/AuthExecutionContext.cs | 9 + .../Auth/Context/AuthFlowContext.cs | 18 +- .../Auth/Context/AuthFlowContextFactory.cs | 45 ++- .../Auth/Context/AuthFlowEndpointFilter.cs | 3 +- .../Auth/Context/DefaultAuthFlow.cs | 4 +- .../Auth/Context/IAuthFlow.cs | 2 +- .../UAuthAuthenticationHandler.cs | 29 +- .../UltimateAuthServerBuilderValidation.cs | 2 +- .../DefaultUAuthCookiePolicyBuilder.cs | 67 ++-- .../Cookies/IUAuthCookiePolicyBuilder.cs | 3 +- .../Abstractions/IPkceEndpointHandler.cs | 16 +- .../Endpoints/DefaultLoginEndpointHandler.cs | 54 ++- .../Endpoints/DefaultLogoutEndpointHandler.cs | 9 +- .../Endpoints/DefaultPkceEndpointHandler.cs | 251 +++++++++++++- .../DefaultRefreshEndpointHandler.cs | 100 ++---- .../DefaultValidateEndpointHandler.cs | 16 +- .../Endpoints/LoginEndpointHandlerBridge.cs | 8 +- .../Endpoints/LogoutEndpointHandlerBridge.cs | 4 +- .../Endpoints/PkceEndpointHandlerBridge.cs | 19 ++ .../Endpoints/RefreshEndpointHandlerBridge.cs | 11 +- .../Endpoints/UAuthEndpointRegistrar.cs | 33 +- .../ValidateEndpointHandlerBridge.cs | 7 +- .../Extensions/AuthFlowContextExtensions.cs | 40 +++ .../Extensions/AuthFlowTypeExtensions.cs | 33 ++ .../Extensions/DeviceExtensions.cs | 2 +- .../Extensions/HttpContextUserExtensions.cs | 6 +- .../UAuthServerServiceCollectionExtensions.cs | 49 ++- .../DefaultTransportCredentialResolver.cs | 4 +- .../AspNetCore/TransportCredential.cs | 4 +- .../DefaultCredentialResponseWriter.cs | 36 +- .../Device/DefaultDeviceContextFactory.cs | 17 + .../{ => Device}/DefaultDeviceResolver.cs | 29 +- .../Device/IDeviceContextFactory.cs | 10 + .../Infrastructure/DeviceInfoFactory.cs | 24 -- .../Hub/DefaultHubCredentialResolver.cs | 40 +++ .../Hub/DefaultHubFlowReader.cs | 41 +++ .../Infrastructure/HubCapabilities.cs | 9 + .../Orchestrator/CreateLoginSessionCommand.cs | 4 +- .../Orchestrator/ISessionCommand.cs | 4 +- .../Orchestrator/ISessionOrchestrator.cs | 4 +- .../Orchestrator/ISessionQueryService.cs | 12 +- .../Orchestrator/RevokeAllChainsCommand.cs | 14 +- .../Orchestrator/RevokeChainCommand.cs | 8 +- .../Orchestrator/RevokeRootCommand.cs | 13 +- .../Orchestrator/RevokeSessionCommand.cs | 4 +- .../Orchestrator/RotateSessionCommand.cs | 4 +- .../Orchestrator/UAuthSessionOrchestrator.cs | 8 +- .../Orchestrator/UAuthSessionQueryService.cs | 52 +-- .../Pkce/IPkceAuthorizationValidator.cs | 6 + .../Pkce/PkceAuthorizationArtifact.cs | 47 +++ .../Pkce/PkceAuthorizationValidator.cs | 70 ++++ .../Pkce/PkceAuthorizeRequest.cs | 8 + .../Pkce/PkceChallengeMethod.cs | 6 + .../Pkce/PkceContextSnapshot.cs | 45 +++ .../Pkce/PkceValidationFailureReason.cs | 11 + .../Pkce/PkceValidationResult.cs | 18 + .../Refresh/DefaultRefreshResponsePolicy.cs | 45 +++ .../Refresh/DefaultRefreshTokenResolver.cs | 5 +- .../Refresh/DefaultSessionRefreshService.cs | 56 ---- .../Refresh/DefaultSessionTouchService.cs | 42 +++ .../Refresh/IRefreshResponsePolicy.cs | 12 + ...reshService.cs => ISessionTouchService.cs} | 4 +- .../Refresh/RefreshStrategyResolver.cs | 21 ++ .../Refresh/SessionTouchPolicy.cs | 7 + .../Session/SessionValidationMapper.cs | 36 ++ .../SessionId/BearerSessionIdResolver.cs | 5 +- .../SessionId/CookieSessionIdResolver.cs | 10 +- .../SessionId/HeaderSessionIdResolver.cs | 9 +- .../SessionId/QuerySessionIdResolver.cs | 11 +- .../Infrastructure/UAuthUserAccessor.cs | 13 +- .../Infrastructure/UserAccessorBridge.cs | 2 +- .../Issuers/UAuthSessionIssuer.cs | 307 +++++------------ .../Issuers/UAuthTokenIssuer.cs | 45 ++- .../Options/UAuthHubOptions.cs | 18 - .../Options/UAuthHubServerOptions.cs | 28 ++ .../Options/UAuthServerOptions.cs | 2 +- .../Services/DefaultRefreshFlowService.cs | 241 ++++++++++++++ .../Services/IRefreshFlowService.cs | 10 + .../Services/IRefreshTokenRotationService.cs | 4 +- .../Services/IUAuthFlowService.cs | 10 +- .../Services/IUAuthTokenService.cs | 28 -- .../Services/RefreshTokenRotationService.cs | 82 +++-- .../Services/UAuthFlowService.cs | 217 +++++------- .../Services/UAuthJwtValidator.cs | 4 +- .../Services/UAuthSessionManager.cs | 90 +++++ .../Services/UAuthSessionService.cs | 112 ------- .../Services/UAuthTokenService.cs | 61 ---- .../Stores/Auth/AuthArtifactKey.cs | 6 + .../Stores/Auth/IAuthStore.cs | 16 + .../Stores/Auth/InMemoryAuthStore.cs | 42 +++ .../Stores/UAuthSessionStoreFactory.cs | 13 +- .../InMemoryCredentialUser.cs | 8 +- .../InMemoryCredentialsSeeder.cs | 3 +- .../InMemoryUserStore.cs | 32 +- .../ServiceCollectionExtensions.cs | 2 +- .../AuthSessionIdEfConverter.cs | 38 +++ .../EfCoreSessionActivityWriter.cs | 33 -- .../EfCoreSessionStore.cs | 312 +++++++++--------- .../EfCoreSessionStoreKernel.cs | 209 +++++++++++- .../EfCoreSessionStoreKernelFactory.cs | 20 ++ .../SessionChainProjection.cs | 7 +- .../EntityProjections/SessionProjection.cs | 8 +- .../SessionRootProjection.cs | 10 +- .../Mappers/SessionChainProjectionMapper.cs | 13 +- .../Mappers/SessionProjectionMapper.cs | 18 +- .../Mappers/SessionRootProjectionMapper.cs | 16 +- .../NullableAuthSessionIdConverter.cs | 12 +- .../ServiceCollectionExtensions.cs | 7 +- .../UAuthSessionDbContext.cs | 46 +-- ...Beam.UltimateAuth.Sessions.InMemory.csproj | 1 + .../IMemorySessionStoreKernel.cs | 147 --------- .../InMemorySessionActivityWriter.cs | 22 -- .../InMemorySessionStore.cs | 194 +++++------ .../InMemorySessionStoreFactory.cs | 15 +- .../InMemorySessionStoreKernel.cs | 139 ++++++++ .../ServiceCollectionExtensions.cs | 6 +- .../AssemblyVisibility.cs | 3 + .../EfCoreTokenStore.cs | 95 ++---- .../Projections/RefreshTokenProjection.cs | 8 +- .../ServiceCollectionExtensions.cs | 2 +- .../UAuthTokenDbContext.cs | 13 +- .../InMemoryRefreshTokenStore.cs | 62 +--- .../ServiceCollectionExtensions.cs | 2 +- .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 60 ++-- .../Core/AuthSessionIdTests.cs | 81 ++++- .../Core/RefreshTokenValidatorTests.cs | 116 +++++++ .../Core/UAuthSessionChainTests.cs | 106 +++++- .../Core/UAuthSessionTests.cs | 25 +- .../Fake/FakeUAuthClient.cs | 25 ++ .../TestIds.cs | 18 + 297 files changed, 5731 insertions(+), 3133 deletions(-) create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs delete mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css delete mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Counter.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs delete mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs rename src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/{ISessionStoreFactory.cs => ISessionStoreKernelFactory.cs} (55%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => Device}/DefaultDeviceResolver.cs (59%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/{ISessionRefreshService.cs => ISessionTouchService.cs} (61%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Stores/Auth/AuthArtifactKey.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Stores/Auth/IAuthStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Stores/Auth/InMemoryAuthStore.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/AssemblyVisibility.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index e91af37b..f0dc5ec9 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -9,11 +9,12 @@ - + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor index 96fbbe6c..74eaceb7 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor @@ -1,4 +1,12 @@ -@inherits LayoutComponentBase +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Server.Infrastructure +@inherits LayoutComponentBase + + + + + + @Body diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs new file mode 100644 index 00000000..d9123d59 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout +{ + public partial class MainLayout + { + + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css deleted file mode 100644 index 38d1f259..00000000 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.css +++ /dev/null @@ -1,98 +0,0 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} - -#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/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Counter.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Counter.razor deleted file mode 100644 index ef23cb31..00000000 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Counter.razor +++ /dev/null @@ -1,18 +0,0 @@ -@page "/counter" - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index 9001e0bd..29c14045 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -1,7 +1,68 @@ @page "/" +@page "/login" +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Authentication +@using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Utilities +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Core.Runtime +@using CodeBeam.UltimateAuth.Server.Abstractions +@using CodeBeam.UltimateAuth.Server.Cookies +@using CodeBeam.UltimateAuth.Server.Infrastructure +@using CodeBeam.UltimateAuth.Server.Services +@using CodeBeam.UltimateAuth.Server.Stores +@inject IUAuthStateManager StateManager +@inject IHubFlowReader HubFlowReader +@inject IHubCredentialResolver HubCredentialResolver +@inject IAuthStore AuthStore +@inject IBrowserStorage BrowserStorage +@inject IUAuthFlowService Flow +@inject ISnackbar Snackbar +@inject IFlowCredentialResolver CredentialResolver +@inject IUAuthClient UAuthClient +@inject NavigationManager Nav +@inject IUAuthProductInfoProvider ProductInfo +@inject AuthenticationStateProvider AuthStateProvider +@inject UAuthClientDiagnostics Diagnostics -Home -

Hello, world!

+
+ + @if (_state == null || !_state.IsActive) + { + + This page cannot be accessed directly. + UAuthHub login flows can only be initiated by an authorized client application. + + return; + } + + + Welcome to UltimateAuth! + + + Login + + + + + Programmatic Pkce Login + + + + @ProductInfo.Get().ProductName v @ProductInfo.Get().Version + Client Profile: @ProductInfo.Get().ClientProfile.ToString() + + + + Hub SessionId: @_state?.HubSessionId + Client Profile: @_state?.ClientProfile + Return Url: @_state?.ReturnUrl + Flow Type: @_state?.FlowType + IsActive: @_state?.IsActive + + +
-Welcome to your new app. 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..7ae27acc --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -0,0 +1,133 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Utilities; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages +{ + public partial class Home + { + [SupplyParameterFromQuery(Name = "hub")] + public string? HubKey { get; set; } + + private string? _username; + private string? _password; + + private HubFlowState? _state; + + protected override async Task OnParametersSetAsync() + { + if (string.IsNullOrWhiteSpace(HubKey)) + { + _state = null; + return; + } + + _state = await HubFlowReader.GetStateAsync(new HubSessionId(HubKey)); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var currentError = await BrowserStorage.GetAsync(StorageScope.Session, "uauth:last_error"); + + if (!string.IsNullOrWhiteSpace(currentError)) + { + Snackbar.Add(ResolveErrorMessage(currentError), Severity.Error); + await BrowserStorage.RemoveAsync(StorageScope.Session, "uauth:last_error"); + } + + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("__uauth_error", out var error)) + { + await BrowserStorage.SetAsync(StorageScope.Session, "uauth:last_error", error.ToString()); + } + + if (string.IsNullOrWhiteSpace(HubKey)) + { + return; + } + + if (_state is null || !_state.Exists) + return; + + if (_state?.IsActive != true) + { + await StartNewPkceAsync(); + return; + } + } + + // For testing & debugging + private async Task ProgrammaticPkceLogin() + { + var hub = _state; + + if (hub is null) + return; + + var credentials = await HubCredentialResolver.ResolveAsync(new HubSessionId(HubKey)); + + var request = new PkceLoginRequest + { + Identifier = "Admin", + Secret = "Password!", + AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty, + CodeVerifier = credentials?.CodeVerifier ?? string.Empty, + ReturnUrl = _state?.ReturnUrl ?? string.Empty + }; + await UAuthClient.CompletePkceLoginAsync(request); + } + + private async Task StartNewPkceAsync() + { + var returnUrl = await ResolveReturnUrlAsync(); + await UAuthClient.BeginPkceAsync(returnUrl); + } + + private async Task ResolveReturnUrlAsync() + { + var fromContext = _state?.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!; + } + + // Config default (recommend adding to options) + //if (!string.IsNullOrWhiteSpace(_options.Login.DefaultReturnUrl)) + // return _options.Login.DefaultReturnUrl!; + + return Nav.Uri; + } + + private string ResolveErrorMessage(string? errorKey) + { + if (errorKey == "invalid") + { + return "Login failed."; + } + + return "Failed attempt."; + } + + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor index 105855d4..91968d6b 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor @@ -1,6 +1,8 @@ - - - - - - + + + + + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor index 741144cf..09765c2c 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor @@ -5,7 +5,12 @@ @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 UltimateAuth.Sample.UAuthHub -@using UltimateAuth.Sample.UAuthHub.Components -@using UltimateAuth.Sample.UAuthHub.Components.Layout +@using CodeBeam.UltimateAuth.Sample.UAuthHub +@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components +@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout +@using CodeBeam.UltimateAuth.Client + +@using MudBlazor +@using MudExtensions diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs new file mode 100644 index 00000000..30e32614 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs @@ -0,0 +1,53 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Stores; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Controllers; + +[Route("uauthhub")] +[IgnoreAntiforgeryToken] +public sealed class HubLoginController : Controller +{ + private readonly IAuthStore _authStore; + private readonly UAuthServerOptions _options; + private readonly IClock _clock; + + public HubLoginController(IAuthStore authStore, IOptions options, IClock clock) + { + _authStore = authStore; + _options = options.Value; + _clock = clock; + } + + [HttpPost("login")] + [IgnoreAntiforgeryToken] + public async Task BeginLogin( + [FromForm] string authorization_code, + [FromForm] string code_verifier, + [FromForm] UAuthClientProfile client_profile, + [FromForm] string? return_url) + { + var hubSessionId = HubSessionId.New(); + + var payload = new HubFlowPayload(); + payload.Set("authorization_code", authorization_code); + payload.Set("code_verifier", code_verifier); + + var artifact = new HubFlowArtifact( + hubSessionId: hubSessionId, + flowType: HubFlowType.Login, + clientProfile: client_profile, + tenantId: null, + returnUrl: return_url, + payload: payload, + expiresAt: _clock.UtcNow.Add(_options.Hub.FlowLifetime)); + + await _authStore.StoreAsync(new AuthArtifactKey(hubSessionId.Value), artifact, HttpContext.RequestAborted); + + return Redirect($"{_options.Hub.LoginPath}?hub={hubSessionId.Value}"); + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs new file mode 100644 index 00000000..eb5fe640 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs @@ -0,0 +1,5 @@ +using CodeBeam.UltimateAuth.Core.Runtime; + +internal sealed class DefaultUAuthHubMarker : IUAuthHubMarker +{ +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index f7a8d49d..67c40553 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -1,13 +1,18 @@ +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Authentication; using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; using MudBlazor.Services; using MudExtensions.Services; -using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; var builder = WebApplication.CreateBuilder(args); @@ -15,6 +20,8 @@ builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); +builder.Services.AddControllers(); + builder.Services.AddMudServices(); builder.Services.AddMudExtensions(); @@ -44,6 +51,14 @@ .AddUltimateAuthInMemoryTokens() .AddUltimateAuthArgon2(); +builder.Services.AddUltimateAuthClient(o => +{ + //o.Refresh.Interval = TimeSpan.FromSeconds(5); + o.Reauth.Behavior = ReauthBehavior.RaiseEvent; +}); + +builder.Services.AddSingleton(); + builder.Services.AddCors(options => { options.AddPolicy("WasmSample", policy => @@ -65,7 +80,7 @@ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } -app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +//app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseHttpsRedirection(); app.UseCors("WasmSample"); @@ -76,6 +91,8 @@ app.MapUAuthEndpoints(); app.MapStaticAssets(); + +app.MapControllers(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); 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 index b9a0c20e..c36676ab 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -2,6 +2,7 @@ @page "/login" @using CodeBeam.UltimateAuth.Client @using CodeBeam.UltimateAuth.Client.Authentication +@using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Diagnostics @using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Runtime @@ -10,9 +11,9 @@ @using CodeBeam.UltimateAuth.Server.Infrastructure @using CodeBeam.UltimateAuth.Server.Services @inject IUAuthStateManager StateManager -@inject IUAuthFlowService Flow +@inject IUAuthFlowService Flow @inject ISnackbar Snackbar -@inject ISessionQueryService SessionQuery +@inject ISessionQueryService SessionQuery @inject IFlowCredentialResolver CredentialResolver @inject IClock Clock @inject IUAuthCookieManager CookieManager @@ -22,6 +23,7 @@ @inject IUAuthProductInfoProvider ProductInfo @inject AuthenticationStateProvider AuthStateProvider @inject UAuthClientDiagnostics Diagnostics +@inject IDeviceIdProvider DeviceIdProvider
@@ -53,7 +55,7 @@ State of Authentication: @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name) - UAuthState @(StateManager.State.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(StateManager.State.UserId) + UAuthState @(StateManager.State.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(StateManager.State.UserKey) Authorized context is shown. @context.User.Identity.IsAuthenticated 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 index c3d3682a..806ffd99 100644 --- 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 @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; @@ -36,14 +38,14 @@ private void OnDiagnosticsChanged() private async Task ProgrammaticLogin() { + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { Identifier = "Admin", Secret = "Password!", + Device = DeviceContext.FromDeviceId(deviceId), }; await UAuthClient.LoginAsync(request); - await UAuthClient.ValidateAsync(); - await StateManager.EnsureAsync(); _authState = await AuthStateProvider.GetAuthenticationStateAsync(); } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor index 792148c7..70173134 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor @@ -1,8 +1,10 @@ - +@using CodeBeam.UltimateAuth.Client.Components + + - + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor index 095f6746..343dbef5 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor @@ -1,11 +1,11 @@ - +@using CodeBeam.UltimateAuth.Client.Components - + @@ -18,4 +18,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor deleted file mode 100644 index ef23cb31..00000000 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Counter.razor +++ /dev/null @@ -1,18 +0,0 @@ -@page "/counter" - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} 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 index fb58035e..e2f5e680 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -1,7 +1,10 @@ @page "/" @page "/login" @using CodeBeam.UltimateAuth.Client.Authentication +@using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Runtime +@using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Runtime @inject IUAuthStateManager StateManager @inject IHttpClientFactory HttpClientFactory @@ -12,6 +15,8 @@ @inject IUAuthProductInfoProvider ProductInfo @inject AuthenticationStateProvider AuthStateProvider @inject UAuthClientDiagnostics Diagnostics +@inject IUAuthClientBootstrapper Bootstrapper +@inject IDeviceIdProvider DeviceIdProvider
@@ -32,6 +37,7 @@ Programmatic Login + Start Pkce Login @@ -40,8 +46,11 @@ + StateHasChanged + Refresh Auth State State of Authentication: - @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name) + From UltimateAuth: @(Auth?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(Auth?.UserKey) + From ASPNET Core: @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name) Authorized context is shown. 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 index 2fdd35bf..f4a6fef7 100644 --- 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 @@ -1,5 +1,8 @@ using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Authentication; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; @@ -7,6 +10,9 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages { public partial class Home { + [CascadingParameter] + public UAuthState Auth { get; set; } + private string? _username; private string? _password; @@ -17,7 +23,7 @@ public partial class Home protected override async Task OnInitializedAsync() { Diagnostics.Changed += OnDiagnosticsChanged; - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + //_authState = await AuthStateProvider.GetAuthenticationStateAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -37,13 +43,20 @@ private void OnDiagnosticsChanged() private async Task ProgrammaticLogin() { + var device = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { Identifier = "Admin", Secret = "Password!", + Device = DeviceContext.FromDeviceId(device), }; await UAuthClient.LoginAsync(request); - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + } + + private async Task StartPkceLogin() + { + await UAuthClient.BeginPkceAsync(); + //await UAuthClient.NavigateToHubLoginAsync(Nav.Uri); } private async Task ValidateAsync() @@ -66,6 +79,11 @@ private async Task RefreshAsync() await UAuthClient.RefreshAsync(); } + private async Task RefreshAuthState() + { + await StateManager.OnLoginAsync(); + } + protected override void OnAfterRender(bool firstRender) { if (firstRender) diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 7fa3f557..98ba1830 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -1,8 +1,6 @@ -using CodeBeam.UltimateAuth.Client.Authentication; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm; -using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using MudBlazor.Services; diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs index dafe7228..efded6e8 100644 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs @@ -19,8 +19,10 @@ public interface IBrowserPostClient /// /// /// - Task FetchPostAsync(string endpoint); + Task FetchPostAsync(string endpoint, IDictionary? data = null); - Task> FetchPostJsonAsync(string url); + //Task> FetchPostJsonAsync(string url, IDictionary? data = null); + + Task FetchPostJsonRawAsync(string endpoint, IDictionary? data = null); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs new file mode 100644 index 00000000..f2f18eba --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Client.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Utilities +{ + public interface IBrowserStorage + { + 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/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs index 8a1a767b..fd9dbae4 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Core.Abstractions; namespace CodeBeam.UltimateAuth.Client.Authentication { @@ -6,26 +7,23 @@ internal sealed class DefaultUAuthStateManager : IUAuthStateManager { private readonly IUAuthClient _client; private readonly IClock _clock; + private readonly IUAuthClientBootstrapper _bootstrapper; public UAuthState State { get; } = UAuthState.Anonymous(); - public DefaultUAuthStateManager(IUAuthClient client, IClock clock) + public DefaultUAuthStateManager(IUAuthClient client, IClock clock, IUAuthClientBootstrapper bootstrapper) { _client = client; _clock = clock; + _bootstrapper = bootstrapper; } public async Task EnsureAsync(CancellationToken ct = default) - { - //if (!State.IsAuthenticated) - // return; - - //if (!State.IsStale) - // return; - + { if (State.IsAuthenticated && !State.IsStale) return; + await _bootstrapper.EnsureStartedAsync(); var result = await _client.ValidateAsync(); if (!result.IsValid) @@ -37,24 +35,12 @@ public async Task EnsureAsync(CancellationToken ct = default) State.ApplySnapshot(result.Snapshot, _clock.UtcNow); } - public async Task OnLoginAsync(CancellationToken ct = default) + public Task OnLoginAsync() { - var result = await _client.ValidateAsync(); - - if (!result.IsValid || result.Snapshot is null) - { - State.Clear(); - return; - } - - var now = _clock.UtcNow; - - State.ApplySnapshot( - result.Snapshot, - validatedAt: now); + State.MarkStale(); + return Task.CompletedTask; } - public Task OnLogoutAsync() { State.Clear(); diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs index efe49e79..e97c8c87 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs @@ -21,7 +21,7 @@ public interface IUAuthStateManager /// /// Called after a successful login. /// - Task OnLoginAsync(CancellationToken ct = default); + Task OnLoginAsync(); /// /// Called after logout. diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs index d41b1f3d..89d48fad 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs @@ -14,13 +14,6 @@ public UAuthAuthenticationStateProvider(IUAuthStateManager stateManager) _stateManager.State.Changed += _ => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } - //public override Task GetAuthenticationStateAsync() - //{ - // _stateManager.EnsureAsync(); - // var principal = _stateManager.State.ToClaimsPrincipal(); - // return Task.FromResult(new AuthenticationState(principal)); - //} - public override Task GetAuthenticationStateAsync() { var principal = _stateManager.State.ToClaimsPrincipal(); diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs new file mode 100644 index 00000000..5c45d210 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Authentication +{ + internal sealed class UAuthCascadingStateProvider : CascadingValueSource, IDisposable + { + private readonly IUAuthStateManager _stateManager; + + public UAuthCascadingStateProvider(IUAuthStateManager stateManager) + : base(() => stateManager.State, isFixed: false) + { + _stateManager = stateManager; + _stateManager.State.Changed += OnStateChanged; + } + + private void OnStateChanged(UAuthStateChangeReason _) + { + NotifyChangedAsync(); + } + + public void Dispose() + { + _stateManager.State.Changed -= OnStateChanged; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs index ffbe01c4..10ef9f57 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs @@ -16,7 +16,7 @@ private UAuthState() { } public bool IsAuthenticated { get; private set; } - public UserId? UserId { get; private set; } + public UserKey? UserKey { get; private set; } public string? TenantId { get; private set; } @@ -44,7 +44,13 @@ private UAuthState() { } internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt) { - UserId = snapshot.UserId; + if (string.IsNullOrWhiteSpace(snapshot.UserId)) + { + Clear(); + return; + } + + UserKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString(snapshot.UserId); TenantId = snapshot.TenantId; Claims = snapshot.Claims; @@ -82,7 +88,7 @@ internal void Clear() { Claims = ClaimsSnapshot.Empty; - UserId = null; + UserKey = null; TenantId = null; IsAuthenticated = false; diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index ecff96b3..3d97996d 100644 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -13,16 +13,19 @@ + + + diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor index 0ab9dbaa..c7678bea 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor @@ -1,6 +1,9 @@ @* TODO: Optional double-submit prevention for native form submit *@ @namespace CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Options +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Contracts @using CodeBeam.UltimateAuth.Core.Options @using Microsoft.Extensions.Options @inject IJSRuntime JS @@ -12,6 +15,15 @@ + + + + @if (LoginType == UAuthLoginType.Pkce) + { + + + + } @ChildContent diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs index acaa36e5..8dbab50d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs @@ -1,12 +1,29 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.JSInterop; namespace CodeBeam.UltimateAuth.Client { public partial class UALoginForm { + [Inject] IDeviceIdProvider DeviceIdProvider { get; set; } = null!; + private DeviceId? _deviceId; + + [Inject] + IHubCredentialResolver HubCredentialResolver { get; set; } = null!; + + [Inject] + IHubFlowReader HubFlowReader { get; set; } = null!; + + [Inject] + IHubCapabilities HubCapabilities { get; set; } = null!; + [Parameter] public string? Identifier { get; set; } @@ -14,11 +31,23 @@ public partial class UALoginForm public string? Secret { get; set; } [Parameter] - public string? Endpoint { get; set; } = "/auth/login"; + public string? Endpoint { get; set; } [Parameter] public string? ReturnUrl { get; set; } + //[Parameter] + //public IHubCredentialResolver? HubCredentialResolver { get; set; } + + //[Parameter] + //public IHubFlowReader? HubFlowReader { get; set; } + + [Parameter] + public HubSessionId? HubSessionId { get; set; } + + [Parameter] + public UAuthLoginType LoginType { get; set; } = UAuthLoginType.Password; + [Parameter] public RenderFragment? ChildContent { get; set; } @@ -27,6 +56,58 @@ public partial class UALoginForm private ElementReference _form; + private HubCredentials? _credentials; + private HubFlowState? _flow; + 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."); + } + + //if (LoginType == UAuthLoginType.Pkce && EffectiveHubSessionId is null) + //{ + // throw new InvalidOperationException("PKCE login requires an active Hub flow. " + + // "No 'hub' query parameter was found." + // ); + //} + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + _deviceId = await DeviceIdProvider.GetOrCreateAsync(); + StateHasChanged(); + } + + public async Task ReloadCredentialsAsync() + { + if (LoginType != UAuthLoginType.Pkce) + return; + + if (HubCredentialResolver is null || EffectiveHubSessionId is null) + return; + + _credentials = await HubCredentialResolver.ResolveAsync(EffectiveHubSessionId.Value); + } + + public async Task ReloadStateAsync() + { + if (LoginType != UAuthLoginType.Pkce || EffectiveHubSessionId is null || HubFlowReader is null) + return; + + _flow = await HubFlowReader.GetStateAsync(EffectiveHubSessionId.Value); + } + public async Task SubmitAsync() { if (_form.Context is null) @@ -37,31 +118,51 @@ public async Task SubmitAsync() private string ClientProfileValue => CoreOptions.Value.ClientProfile.ToString(); + private string EffectiveEndpoint => LoginType == UAuthLoginType.Pkce + ? Options.Value.Endpoints.PkceComplete + : Options.Value.Endpoints.Login; + + private string ResolvedEndpoint { get { var loginPath = string.IsNullOrWhiteSpace(Endpoint) - ? Options.Value.Endpoints.Login + ? EffectiveEndpoint : Endpoint; - var baseUrl = UAuthUrlBuilder.Combine( - Options.Value.Endpoints.Authority, - loginPath); - + var baseUrl = UAuthUrlBuilder.Combine(Options.Value.Endpoints.Authority, loginPath); var returnUrl = EffectiveReturnUrl; if (string.IsNullOrWhiteSpace(returnUrl)) return baseUrl; - return $"{baseUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}"; + return $"{baseUrl}?{(_credentials != null ? "hub=" + EffectiveHubSessionId + "&" : null)}returnUrl={Uri.EscapeDataString(returnUrl)}"; } } - private string EffectiveReturnUrl => - !string.IsNullOrWhiteSpace(ReturnUrl) + private string EffectiveReturnUrl => !string.IsNullOrWhiteSpace(ReturnUrl) ? ReturnUrl - : Navigation.Uri; + : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl ?? string.Empty : 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("hub", out var hubValue) && CodeBeam.UltimateAuth.Core.Domain.HubSessionId.TryParse(hubValue, out var parsed)) + { + return parsed; + } + + return null; + } + } } } diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor new file mode 100644 index 00000000..f0769183 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor @@ -0,0 +1,18 @@ +@using CodeBeam.UltimateAuth.Client.Runtime +@inject IUAuthClientBootstrapper Bootstrapper + + + @ChildContent + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + await Bootstrapper.EnsureStartedAsync(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor new file mode 100644 index 00000000..921b35e6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor @@ -0,0 +1,13 @@ +@using CodeBeam.UltimateAuth.Client.Authentication +@using CodeBeam.UltimateAuth.Client.Runtime +@using CodeBeam.UltimateAuth.Core.Contracts +@using Microsoft.AspNetCore.Components.Authorization +@inject IUAuthStateManager StateManager +@inject AuthenticationStateProvider AuthStateProvider +@inject IUAuthClientBootstrapper Bootstrapper + + + + @ChildContent + + diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs new file mode 100644 index 00000000..f66787af --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs @@ -0,0 +1,46 @@ +using CodeBeam.UltimateAuth.Client.Authentication; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Components +{ + public partial class UAuthAuthenticationState + { + private bool _initialized; + private UAuthState _uauthState; + + [Parameter] + public RenderFragment ChildContent { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + if (_initialized) + return; + + _initialized = true; + //await Bootstrapper.EnsureStartedAsync(); + await StateManager.EnsureAsync(); + _uauthState = StateManager.State; + + StateManager.State.Changed += OnStateChanged; + } + + private void OnStateChanged(UAuthStateChangeReason _) + { + //StateManager.EnsureAsync(); + if (_ == UAuthStateChangeReason.MarkedStale) + { + StateManager.EnsureAsync(); + } + _uauthState = StateManager.State; + InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + StateManager.State.Changed -= OnStateChanged; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor index 6f5d1de5..89aeb7f6 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor @@ -1,5 +1,9 @@ @namespace CodeBeam.UltimateAuth.Client @using CodeBeam.UltimateAuth.Client.Abstractions +@using CodeBeam.UltimateAuth.Client.Device +@using CodeBeam.UltimateAuth.Client.Infrastructure +@inject IDeviceIdProvider DeviceIdProvider +@inject IBrowserUAuthBridge BrowserUAuthBridge @inject ISessionCoordinator Coordinator @implements IAsyncDisposable diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs index a958d7bc..c3d28274 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs @@ -22,7 +22,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) return; _started = true; + // TODO: Add device id auto creation for MVC, this is only for blazor. + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); + await BrowserUAuthBridge.SetDeviceIdAsync(deviceId.Value); await Coordinator.StartAsync(); + StateHasChanged(); } private async void HandleReauthRequired() diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs index e6449d05..643423df 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs @@ -4,6 +4,7 @@ public sealed record BrowserPostJsonResult { public bool Ok { get; init; } public int Status { get; init; } + public string? RefreshOutcome { get; init; } public T? Body { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs new file mode 100644 index 00000000..446b9823 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Client.Contracts +{ + public sealed class BrowserPostRawResult + { + public bool Ok { get; init; } + public int Status { get; init; } + public string? RefreshOutcome { get; init; } + public JsonElement? Body { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs new file mode 100644 index 00000000..a8fcad43 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client.Contracts +{ + internal sealed class PkceClientState + { + public string Verifier { get; init; } = default!; + public string AuthorizationCode { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs new file mode 100644 index 00000000..9e823eef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client.Contracts +{ + public enum StorageScope + { + Session, + Local + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs new file mode 100644 index 00000000..7bb5f7f2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Utilities; + +namespace CodeBeam.UltimateAuth.Client.Device; + +public sealed class BrowserDeviceIdStorage : IDeviceIdStorage +{ + private const string Key = "udid"; + private readonly IBrowserStorage _storage; + + public BrowserDeviceIdStorage(IBrowserStorage storage) + { + _storage = storage; + } + + public async ValueTask LoadAsync(CancellationToken ct = default) + { + if (!await _storage.ExistsAsync(StorageScope.Local, Key)) + return null; + + return await _storage.GetAsync(StorageScope.Local, Key); + } + + public ValueTask SaveAsync(string deviceId, CancellationToken ct = default) + { + return _storage.SetAsync(StorageScope.Local, Key, deviceId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs b/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs new file mode 100644 index 00000000..ccca8c95 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Cryptography; + +namespace CodeBeam.UltimateAuth.Client.Devices; + +public sealed class DefaultDeviceIdGenerator : 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/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs b/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs new file mode 100644 index 00000000..c1f504a8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Devices; + +public sealed class DefaultDeviceIdProvider : IDeviceIdProvider +{ + private readonly IDeviceIdStorage _storage; + private readonly IDeviceIdGenerator _generator; + + private DeviceId? _cached; + + public DefaultDeviceIdProvider(IDeviceIdStorage storage, IDeviceIdGenerator generator) + { + _storage = storage; + _generator = generator; + } + + public async ValueTask GetOrCreateAsync(CancellationToken ct = default) + { + if (_cached is not null) + return _cached.Value; + + 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; + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs new file mode 100644 index 00000000..b19b0dc7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Device +{ + public interface IDeviceIdGenerator + { + DeviceId Generate(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs new file mode 100644 index 00000000..f8983b5a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Device +{ + public interface IDeviceIdProvider + { + ValueTask GetOrCreateAsync(CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs new file mode 100644 index 00000000..c3555525 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client.Device +{ + public interface IDeviceIdStorage + { + ValueTask LoadAsync(CancellationToken ct = default); + ValueTask SaveAsync(string deviceId, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs index 0db7156b..752739e9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs @@ -1,10 +1,15 @@ using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Client.Devices; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Client.Utilities; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -103,11 +108,24 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol : sp.GetRequiredService(); }); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); return services; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs index 4bc6e0de..02bc7bc5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs @@ -1,7 +1,6 @@ using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using Microsoft.JSInterop; @@ -29,31 +28,48 @@ public Task NavigatePostAsync(string endpoint, IDictionary? data }).AsTask(); } - public async Task FetchPostAsync(string endpoint) + public async Task FetchPostAsync(string endpoint, IDictionary? data = null) { var result = await _js.InvokeAsync("uauth.post", new { url = endpoint, mode = "fetch", expectJson = false, + data = data, clientProfile = _coreOptions.ClientProfile.ToString() }); return result; } - public async Task> FetchPostJsonAsync(string endpoint) + public async Task FetchPostJsonRawAsync(string endpoint, IDictionary? data = null) { - var result = await _js.InvokeAsync>("uauth.post", new - { - url = endpoint, - mode = "fetch", - expectJson = true, - clientProfile = _coreOptions.ClientProfile.ToString() - }); - - return result; + var postData = data ?? new Dictionary(); + return await _js.InvokeAsync("uauth.post", + new + { + url = endpoint, + mode = "fetch", + expectJson = true, + data = postData, + clientProfile = _coreOptions.ClientProfile.ToString() + }); } + + //public async Task> FetchPostJsonAsync(string endpoint, IDictionary? data = null) + //{ + // var result = await _js.InvokeAsync>("uauth.post", new + // { + // url = endpoint, + // mode = "fetch", + // expectJson = true, + // data = data, + // clientProfile = _coreOptions.ClientProfile.ToString() + // }); + + // return result; + //} + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs new file mode 100644 index 00000000..b62442d2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using Microsoft.JSInterop; + +namespace CodeBeam.UltimateAuth.Client.Utilities +{ + public sealed class BrowserStorage : IBrowserStorage + { + private readonly IJSRuntime _js; + + public BrowserStorage(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/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs new file mode 100644 index 00000000..2fa8642c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs @@ -0,0 +1,18 @@ +using Microsoft.JSInterop; + +namespace CodeBeam.UltimateAuth.Client.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/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs new file mode 100644 index 00000000..d6ed1395 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal interface IBrowserUAuthBridge +{ + ValueTask SetDeviceIdAsync(string deviceId); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs new file mode 100644 index 00000000..24042f5c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure +{ + internal sealed class NoOpHubCapabilities : IHubCapabilities + { + public bool SupportsPkce => false; + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs new file mode 100644 index 00000000..658a8653 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs @@ -0,0 +1,10 @@ +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/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs new file mode 100644 index 00000000..9b6a7768 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs @@ -0,0 +1,10 @@ +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/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs new file mode 100644 index 00000000..4f8f3ba8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Options +{ + public sealed class PkceLoginOptions + { + /// + /// Enables PKCE login support. + /// + public bool Enabled { get; set; } = true; + + public string? ReturnUrl { get; init; } + + /// + /// Called after authorization_code is issued, + /// before redirecting to the Hub. + /// + public Func? OnAuthorized { get; init; } + + /// + /// If false, BeginPkceAsync will NOT redirect automatically. + /// Caller is responsible for navigation. + /// + public bool AutoRedirect { get; init; } = true; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index 0e6ffd10..97a8aeed 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Client.Options { public sealed class UAuthClientOptions { public AuthEndpointOptions Endpoints { get; set; } = new(); + public LoginOptions Login { get; set; } = new(); public UAuthClientRefreshOptions Refresh { get; set; } = new(); public ReauthOptions Reauth { get; init; } = new(); } @@ -22,6 +22,28 @@ public sealed class AuthEndpointOptions public string Refresh { get; set; } = "/auth/refresh"; public string Reauth { get; set; } = "/auth/reauth"; public string Validate { get; set; } = "/auth/validate"; + public string PkceAuthorize { get; set; } = "/auth/pkce/authorize"; + public string PkceComplete { get; set; } = "/auth/pkce/complete"; + public string HubLoginPath { get; set; } = "/uauthhub/login"; + } + + public sealed class LoginOptions + { + /// + /// Default return URL after a successful login flow. + /// If not set, current location will be used. + /// + public string? DefaultReturnUrl { get; set; } + + /// + /// Options related to PKCE-based login flows. + /// + public PkceLoginOptions Pkce { get; set; } = new(); + + /// + /// Enables or disables direct credential-based login. + /// + public bool AllowDirectLogin { get; set; } = true; } public sealed class UAuthClientRefreshOptions diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs index 0101507e..5d3e8a3a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Client.Options @@ -7,6 +8,9 @@ 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; diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs new file mode 100644 index 00000000..222d8eee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace CodeBeam.UltimateAuth.Client.Runtime +{ + public interface IUAuthClientBootstrapper + { + Task EnsureStartedAsync(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs new file mode 100644 index 00000000..0acb423e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs @@ -0,0 +1,53 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Client.Infrastructure; + +// DeviceId is automatically created and managed by UAuthClientProvider. This class is for advanced situations. +namespace CodeBeam.UltimateAuth.Client.Runtime +{ + internal sealed class UAuthClientBootstrapper : IUAuthClientBootstrapper + { + private readonly SemaphoreSlim _gate = new(1, 1); + private bool _started; + + private readonly IDeviceIdProvider _deviceIdProvider; + private readonly IBrowserUAuthBridge _browser; + private readonly ISessionCoordinator _coordinator; + + public bool IsStarted => _started; + + public UAuthClientBootstrapper( + IDeviceIdProvider deviceIdProvider, + IBrowserUAuthBridge browser, + ISessionCoordinator coordinator) + { + _deviceIdProvider = deviceIdProvider; + _browser = browser; + _coordinator = coordinator; + } + + public async Task EnsureStartedAsync() + { + if (_started) + return; + + await _gate.WaitAsync(); + try + { + if (_started) + return; + + var deviceId = await _deviceIdProvider.GetOrCreateAsync(); + await _browser.SetDeviceIdAsync(deviceId.Value); + await _coordinator.StartAsync(); + + _started = true; + } + finally + { + _gate.Release(); + } + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs index 3c3416ac..5bdcfe56 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -1,6 +1,5 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Core.Contracts; -using System.Security.Claims; namespace CodeBeam.UltimateAuth.Client { @@ -12,5 +11,8 @@ public interface IUAuthClient Task ReauthAsync(); Task ValidateAsync(); + + Task BeginPkceAsync(string? returnUrl = null); + Task CompletePkceLoginAsync(PkceLoginRequest request); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index 086c3b89..1cf53ebd 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -5,10 +5,15 @@ using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; -using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Client { @@ -16,16 +21,22 @@ internal sealed class UAuthClient : IUAuthClient { private readonly IBrowserPostClient _post; private readonly UAuthClientOptions _options; + private readonly UAuthOptions _coreOptions; private readonly UAuthClientDiagnostics _diagnostics; + private readonly NavigationManager _nav; public UAuthClient( IBrowserPostClient post, IOptions options, - UAuthClientDiagnostics diagnostics) + IOptions coreOptions, + UAuthClientDiagnostics diagnostics, + NavigationManager nav) { _post = post; _options = options.Value; + _coreOptions = coreOptions.Value; _diagnostics = diagnostics; + _nav = nav; } public async Task LoginAsync(LoginRequest request) @@ -83,19 +94,131 @@ public async Task ReauthAsync() public async Task ValidateAsync() { var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate); - var result = await _post.FetchPostJsonAsync(url); + var raw = await _post.FetchPostJsonRawAsync(url); - if (result.Body is null) - return new AuthValidationResult { IsValid = false, State = "transport" }; + if (!raw.Ok || raw.Body is null) + { + return new AuthValidationResult + { + IsValid = false, + State = "transport" + }; + } + + var body = raw.Body.Value.Deserialize( + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); - return new AuthValidationResult + return body ?? new AuthValidationResult { - IsValid = result.Body.IsValid, - State = result.Body.State, - RemainingAttempts = result.Body.RemainingAttempts, - Snapshot = result.Body.Snapshot, + IsValid = false, + State = "deserialize" }; } + public async Task BeginPkceAsync(string? returnUrl = null) + { + var pkce = _options.Login.Pkce; + + if (!pkce.Enabled) + throw new InvalidOperationException("PKCE login is disabled by configuration."); + + var verifier = CreateVerifier(); + var challenge = CreateChallenge(verifier); + + var authorizeUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceAuthorize); + + var raw = await _post.FetchPostJsonRawAsync( + 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.DefaultReturnUrl + ?? _nav.Uri; + + if (pkce.AutoRedirect) + { + await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl); + } + } + + public async Task CompletePkceLoginAsync(PkceLoginRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _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 + }; + + await _post.NavigatePostAsync(url, payload); + } + + private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) + { + var hubLoginUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.HubLoginPath); + + var data = new Dictionary + { + ["authorization_code"] = authorizationCode, + ["code_verifier"] = codeVerifier, + ["return_url"] = returnUrl, + ["client_profile"] = _coreOptions.ClientProfile.ToString() + }; + + return _post.NavigatePostAsync(hubLoginUrl, data); + } + + + // ---------------- PKCE CRYPTO ---------------- + + private static string CreateVerifier() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Base64UrlEncode(bytes); + } + + private static string CreateChallenge(string verifier) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); + return Base64UrlEncode(hash); + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js index 07e521b1..94c26bc6 100644 --- a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js +++ b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js @@ -1,65 +1,141 @@ -window.uauth = { - submitForm: function (form) { - if (form) { - form.submit(); - } +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 = { - post: async function (options) { - const { - url, - mode, - data, - expectJson, - 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); - - 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); - } - } +window.uauth.submitForm = function (form) { + if (!form) + return; - document.body.appendChild(form); - form.submit(); - return null; - } + if (!window.uauth.deviceId) { + throw new Error("UAuth deviceId is not initialized."); + } + + //if (!form.querySelector("input[name='__uauth_device']")) { + const udid = document.createElement("input"); + udid.type = "hidden"; + udid.name = "__uauth_device"; + udid.value = window.uauth.deviceId; + form.appendChild(udid); + //} + + form.submit(); +}; + +window.uauth.post = async function (options) { + const { + url, + mode, + data, + expectJson, + clientProfile + } = options; - const response = await fetch(url, { - method: "POST", - credentials: "include", - headers: { - "X-UAuth-ClientProfile": clientProfile + 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 + }; - let body = null; - if (expectJson) { - try { body = await response.json(); } catch { } + if (data) { + body = new URLSearchParams(); + for (const key in data) { + body.append(key, data[key]); } - return { - ok: response.ok, - status: response.status, - refreshOutcome: response.headers.get("X-UAuth-Refresh"), - body: body - }; + headers["Content-Type"] = "application/x-www-form-urlencoded"; } + + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: headers, + body: body + }); + + let responseBody = null; + if (expectJson) { + 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; }; 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..36bd1b34 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs @@ -0,0 +1,7 @@ +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..78ecb59f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs @@ -0,0 +1,9 @@ +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..82764fb4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs @@ -0,0 +1,9 @@ +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/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index 4dcb392d..39ec7c59 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -3,18 +3,18 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions { - public interface ISessionIssuer + public interface ISessionIssuer { - Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); - Task> RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); + Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default); - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default); + Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default); - Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); + Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); - Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at,CancellationToken ct = default); + Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at,CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs index 269cb47b..c2c34239 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs @@ -8,7 +8,7 @@ public interface IUAuthService { //IUAuthFlowService Flow { get; } - IUAuthSessionService Sessions { get; } + IUAuthSessionManager Sessions { get; } //IUAuthTokenService Tokens { get; } IUAuthUserService Users { get; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs new file mode 100644 index 00000000..2f759e92 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation. + /// + public interface IUAuthSessionManager + { + Task> GetChainsAsync(string? tenantId, UserKey userKey); + + Task> GetSessionsAsync(string? tenantId, SessionChainId chainId); + + Task GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId); + + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at); + + Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at); + + Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId); + + Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at); + + // Hard revoke - admin + Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs deleted file mode 100644 index 73228f14..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation. - /// - /// The type used to uniquely identify the user. - public interface IUAuthSessionService - { - Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at); - - Task>> GetChainsAsync(string? tenantId, TUserId userId); - - Task>> GetSessionsAsync(string? tenantId, ChainId chainId); - - Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId); - - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at); - - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at); - - Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId); - - Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at); - - // Hard revoke - admin - Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at); - - Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken cancellationToken = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs index 543ec2f0..25fac768 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs @@ -1,21 +1,20 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Core.Abstractions { /// /// Default session store factory that throws until a real store implementation is registered. /// - public sealed class DefaultSessionStoreFactory : ISessionStoreFactory + internal sealed class DefaultSessionStoreFactory : ISessionStoreKernelFactory { - /// Creates a session store instance for the given user ID type, but always throws because no store has been registered. - /// The tenant identifier, or null in single-tenant mode. - /// The type used to uniquely identify the user. - /// Never returns; always throws. - /// Thrown when no session store implementation has been configured. - public ISessionStoreKernel Create(string? tenantId) + private readonly IServiceProvider _sp; + + public DefaultSessionStoreFactory(IServiceProvider sp) { - throw new InvalidOperationException( - "No session store has been configured." + - "Call AddUltimateAuthServer().AddSessionStore(...) to register one." - ); + _sp = sp; } + + public ISessionStoreKernel Create(string? tenantId) + => _sp.GetRequiredService(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs index 6e52d309..edf2d58e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs @@ -6,21 +6,10 @@ /// public interface IAccessTokenIdStore { - Task StoreAsync( - string? tenantId, - string jti, - DateTimeOffset expiresAt, - CancellationToken ct = default); + Task StoreAsync(string? tenantId, string jti, DateTimeOffset expiresAt, CancellationToken ct = default); - Task IsRevokedAsync( - string? tenantId, - string jti, - CancellationToken ct = default); + Task IsRevokedAsync(string? tenantId, string jti, CancellationToken ct = default); - Task RevokeAsync( - string? tenantId, - string jti, - DateTimeOffset revokedAt, - CancellationToken ct = default); + Task RevokeAsync(string? tenantId, 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 index cb360592..eb6e52c3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -6,33 +6,17 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; /// Low-level persistence abstraction for refresh tokens. /// NO validation logic. NO business rules. /// -public interface IRefreshTokenStore +public interface IRefreshTokenStore { - Task StoreAsync(string? tenantId, - StoredRefreshToken token, - CancellationToken ct = default); + Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default); - Task?> FindByHashAsync(string? tenantId, - string tokenHash, - CancellationToken ct = default); + Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default); - Task RevokeAsync(string? tenantId, - string tokenHash, - DateTimeOffset revokedAt, - CancellationToken ct = default); + Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); - Task RevokeBySessionAsync(string? tenantId, - AuthSessionId sessionId, - DateTimeOffset revokedAt, - CancellationToken ct = default); + Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); - Task RevokeByChainAsync(string? tenantId, - ChainId chainId, - DateTimeOffset revokedAt, - CancellationToken ct = default); + Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); - Task RevokeAllForUserAsync(string? tenantId, - TUserId userId, - DateTimeOffset revokedAt, - CancellationToken ct = default); + Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs deleted file mode 100644 index 8a2c9106..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionActivityWriter.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface ISessionActivityWriter - { - Task TouchAsync(string? tenantId, ISession session, CancellationToken ct); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 902b9051..cca6ea0a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -7,22 +7,24 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// High-level session store abstraction used by UltimateAuth. /// Encapsulates session, chain, and root orchestration. /// - public interface ISessionStore + public interface ISessionStore { /// /// Retrieves an active session by id. /// - Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); + Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); /// /// Creates a new session and associates it with the appropriate chain and root. /// - Task CreateSessionAsync(IssuedSession issuedSession, SessionStoreContext context, CancellationToken ct = default); + Task CreateSessionAsync(IssuedSession issuedSession, SessionStoreContext context, CancellationToken ct = default); /// /// Refreshes (rotates) the active session within its chain. /// - Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession newSession, SessionStoreContext context, CancellationToken ct = default); + Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession newSession, SessionStoreContext context, CancellationToken ct = default); + + Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default); /// /// Revokes a single session. @@ -32,11 +34,13 @@ public interface ISessionStore /// /// Revokes all sessions for a specific user (all devices). /// - Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default); + Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); /// /// Revokes all sessions within a specific chain (single device). /// - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default); + Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + + Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs index 34e9afe4..578f2427 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs @@ -2,137 +2,35 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions { - /// - /// Defines the low-level persistence operations for sessions, session chains, and session roots in a multi-tenant or single-tenant environment. - /// Store implementations provide durable and atomic data access. - /// - public interface ISessionStoreKernel + public interface ISessionStoreKernel { - /// - /// Executes multiple store operations as a single atomic unit. - /// Implementations must ensure transactional consistency where supported. - /// - Task ExecuteAsync(Func action); - - /// - /// Retrieves a session by its identifier within the given tenant context. - /// - /// The tenant identifier, or null for single-tenant mode. - /// The session identifier. - /// The session instance or null if not found. - Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId); - - /// - /// Persists a new session or updates an existing one within the tenant scope. - /// Implementations must ensure atomic writes. - /// - /// The tenant identifier, or null. - /// The session to persist. - Task SaveSessionAsync(string? tenantId, ISession session); - - /// - /// Marks the specified session as revoked, preventing future authentication. - /// Revocation timestamp must be stored reliably. - /// - /// The tenant identifier, or null. - /// The session identifier. - /// The UTC timestamp of revocation. - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at); - - /// - /// Returns all sessions belonging to the specified chain, ordered according to store implementation rules. - /// - /// The tenant identifier, or null. - /// The chain identifier. - /// A read-only list of sessions. - Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId); - - /// - /// Retrieves a session chain by identifier. Returns null if the chain does not exist in the provided tenant context. - /// - /// The tenant identifier, or null. - /// The chain identifier. - /// The chain or null. - Task?> GetChainAsync(string? tenantId, ChainId chainId); - - /// - /// Inserts a new session chain into the store. Implementations must ensure consistency with the related sessions and session root. - /// - /// The tenant identifier, or null. - /// The chain to save. - Task SaveChainAsync(string? tenantId, ISessionChain chain); - - /// - /// Marks the entire session chain as revoked, invalidating all associated sessions for the device or app family. - /// - /// The tenant identifier, or null. - /// The chain to revoke. - /// The UTC timestamp of revocation. - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at); - - /// - /// Retrieves the active session identifier for the specified chain. - /// This is typically an O(1) lookup and used for session rotation. - /// - /// The tenant identifier, or null. - /// The chain whose active session is requested. - /// The active session identifier or null. - Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId); - - /// - /// Sets or replaces the active session identifier for the specified chain. - /// Must be atomic to prevent race conditions during refresh. - /// - /// The tenant identifier, or null. - /// The chain whose active session is being set. - /// The new active session identifier. - Task SetActiveSessionIdAsync(string? tenantId, ChainId chainId, AuthSessionId sessionId); - - /// - /// Retrieves all session chains belonging to the specified user within the tenant scope. - /// - /// The tenant identifier, or null. - /// The user whose chains are being retrieved. - /// A read-only list of session chains. - Task>> GetChainsByUserAsync(string? tenantId, TUserId userId); - - /// - /// Retrieves the session root for the user, which represents the full set of chains and their associated security metadata. - /// Returns null if the root does not exist. - /// - /// The tenant identifier, or null. - /// The user identifier. - /// The session root or null. - Task?> GetSessionRootAsync(string? tenantId, TUserId userId); - - /// - /// Persists a session root structure, usually after chain creation, rotation, or security operations. - /// - /// The tenant identifier, or null. - /// The session root to save. - Task SaveSessionRootAsync(string? tenantId, ISessionRoot root); - - /// - /// Revokes the session root, invalidating all chains and sessions belonging to the specified user in the tenant scope. - /// - /// The tenant identifier, or null. - /// The user whose root should be revoked. - /// The UTC timestamp of revocation. - Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTimeOffset at); - - /// - /// Removes expired sessions from the store while leaving chains and session roots intact. Cleanup strategy is determined by the store implementation. - /// - /// The tenant identifier, or null. - /// The current UTC timestamp. - Task DeleteExpiredSessionsAsync(string? tenantId, DateTimeOffset at); - - /// - /// Retrieves the chain identifier associated with the specified session. - /// - /// The tenant identifier, or null. - /// The session identifier. - /// The chain identifier or null. - Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId); + Task ExecuteAsync(Func action, CancellationToken ct = default); + //string? TenantId { get; } + + // Session + Task GetSessionAsync(AuthSessionId sessionId); + Task SaveSessionAsync(ISession session); + Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); + + // Chain + Task GetChainAsync(SessionChainId chainId); + Task SaveChainAsync(ISessionChain chain); + Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at); + Task GetActiveSessionIdAsync(SessionChainId chainId); + Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId); + + // Root + Task GetSessionRootByUserAsync(UserKey userKey); + Task GetSessionRootByIdAsync(SessionRootId rootId); + Task SaveSessionRootAsync(ISessionRoot root); + Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at); + + // Helpers + Task GetChainIdBySessionAsync(AuthSessionId sessionId); + Task> GetChainsByUserAsync(UserKey userKey); + Task> GetSessionsByChainAsync(SessionChainId chainId); + + // Maintenance + Task DeleteExpiredSessionsAsync(DateTimeOffset at); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs similarity index 55% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs index a49165d0..b529fa62 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs @@ -3,23 +3,19 @@ /// /// 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. + /// Implementations typically resolve concrete types from the dependency injection container. /// - public interface ISessionStoreFactory + public interface ISessionStoreKernelFactory { /// /// Creates and returns a session store instance for the specified user ID type within the given tenant context. /// - /// The type used to uniquely identify users. /// /// The tenant identifier for multi-tenant environments, or null for single-tenant mode. /// /// - /// An implementation able to perform session persistence operations. + /// An implementation able to perform session persistence operations. /// - /// - /// Thrown if no compatible session store implementation is registered. - /// - ISessionStoreKernel Create(string? tenantId); + ISessionStoreKernel Create(string? tenantId); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs new file mode 100644 index 00000000..2a90b92c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface ITenantAwareSessionStore + { + void BindTenant(string? tenantId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs index e8a181ba..96f4f54d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs @@ -18,7 +18,6 @@ public interface IUAuthUserStore /// Retrieves a user by a login credential such as username or email. /// Returns null if no matching user exists. /// - /// The login value used to locate the user. /// The user instance or null if not found. Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken token = default); @@ -27,16 +26,13 @@ public interface IUAuthUserStore /// in password-based authentication. Returns null for passwordless users /// (e.g., external login or passkey-only accounts). /// - /// The user identifier. /// The password hash or null. Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken token = default); /// /// Updates the password hash for the specified user. This method is invoked by - /// password management services and not by . + /// password management services and not by . /// - /// The user identifier. - /// The new password hash value. Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default); /// @@ -44,7 +40,6 @@ public interface IUAuthUserStore /// This value increments whenever critical security actions occur, such as: /// password reset, MFA reset, external login removal, or account recovery. /// - /// The user identifier. /// The current security version. Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default); @@ -52,7 +47,6 @@ public interface IUAuthUserStore /// Increments the user's security version, invalidating all existing sessions. /// This is typically called after sensitive security events occur. /// - /// The user identifier. Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs index 78c6c8ef..e30f68f6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs @@ -2,11 +2,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; -public interface IRefreshTokenValidator +public interface IRefreshTokenValidator { - Task> ValidateAsync( - string? tenantId, - string refreshToken, - DateTimeOffset now, - CancellationToken ct = default); + Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs index bce952e5..d31c6e24 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed record AuthContext { @@ -8,54 +10,10 @@ public sealed record AuthContext public UAuthMode Mode { get; init; } - public SessionAccessContext? Session { get; init; } + public SessionSecurityContext? Session { get; init; } - public DeviceContext Device { get; init; } + public required DeviceContext Device { get; init; } public DateTimeOffset At { get; init; } - - private AuthContext() { } - - public static AuthContext System(string? tenantId, AuthOperation operation, DateTimeOffset at, UAuthMode mode = UAuthMode.Hybrid) - { - return new AuthContext - { - TenantId = tenantId, - Operation = operation, - Mode = mode, - At = at, - Session = null, - Device = null - }; - } - - public static AuthContext ForAuthenticatedUser(string? tenantId, AuthOperation operation, DateTimeOffset at, DeviceContext device, UAuthMode mode = UAuthMode.Hybrid) - { - return new AuthContext - { - TenantId = tenantId, - Operation = operation, - Mode = mode, - At = at, - Device = device, - Session = null - }; - } - - public static AuthContext ForSession(string? tenantId, AuthOperation operation, SessionAccessContext session, DateTimeOffset at, - DeviceContext device, UAuthMode mode = UAuthMode.Hybrid) - { - return new AuthContext - { - TenantId = tenantId, - Operation = operation, - Mode = mode, - At = at, - Session = session, - Device = device - }; - } - - } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs index 76145d2e..53027dae 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed record AuthenticationContext { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs deleted file mode 100644 index 8cbdeff9..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record DeviceContext - { - public string DeviceId { get; init; } = default!; - - public bool IsKnownDevice { get; init; } - - public bool IsTrusted { get; init; } - - public string? Platform { get; init; } - - public string? UserAgent { get; init; } - - public static DeviceContext From(DeviceInfo info) - { - return new DeviceContext - { - DeviceId = info.DeviceId, - Platform = info.Platform, - UserAgent = info.UserAgent - }; - } - } - -} 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..38760414 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class DeviceInfo +{ + public required DeviceId DeviceId { get; init; } + + /// + /// High-level platform classification (web, mobile, desktop, iot). + /// Used for analytics and policy decisions. + /// + public string? Platform { 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; } + + /// + /// 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/SessionAccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs deleted file mode 100644 index 4a32bf35..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record SessionAccessContext - { - public SessionState State { get; init; } - - public bool IsExpired { get; init; } - - public bool IsRevoked { get; init; } - - public string? ChainId { get; init; } - - public string? BoundDeviceId { get; init; } - } - -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index 69a742ac..3ff02bd0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -8,7 +8,7 @@ public sealed record LoginRequest public string Identifier { get; init; } = default!; // username, email etc. public string Secret { get; init; } = default!; // password public DateTimeOffset? At { get; init; } - public DeviceInfo DeviceInfo { get; init; } + public required DeviceContext Device { get; init; } public IReadOnlyDictionary? Metadata { get; init; } /// @@ -18,6 +18,6 @@ public sealed record LoginRequest public bool RequestTokens { get; init; } = true; // Optional - public ChainId? ChainId { get; init; } + public SessionChainId? ChainId { 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..4263a08f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum UAuthLoginType + { + Password, // /auth/login + Pkce // /auth/pkce/complete + } +} 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..152afcae --- /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 string AuthorizationCode { get; init; } = default!; + public int ExpiresIn { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs deleted file mode 100644 index 1a4d986c..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record PkceChallengeResult - { - public string Challenge { get; init; } = default!; - public string Method { get; init; } = "S256"; - } -} 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..12a10364 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + internal sealed class PkceCompleteRequest + { + public string AuthorizationCode { get; init; } = default!; + public string CodeVerifier { get; init; } = default!; + public string Identifier { get; init; } = default!; + public string Secret { get; init; } = default!; + public string ReturnUrl { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs deleted file mode 100644 index 153e865b..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record PkceConsumeRequest - { - public string Challenge { get; init; } = default!; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs deleted file mode 100644 index bd8eb88e..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record PkceCreateRequest - { - public string ClientId { get; init; } = default!; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs new file mode 100644 index 00000000..6c0a2f89 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class PkceLoginRequest +{ + public string AuthorizationCode { get; init; } = default!; + public string CodeVerifier { get; init; } = default!; + public string ReturnUrl { get; init; } = default!; + + public string Identifier { get; init; } = default!; + public string Secret { get; init; } = default!; + + public string? TenantId { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs deleted file mode 100644 index c094b0a3..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record PkceVerificationResult - { - public bool IsValid { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs deleted file mode 100644 index 9a1d588d..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record PkceVerifyRequest - { - public string Challenge { get; init; } = default!; - public string Verifier { get; init; } = default!; - } -} 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..21b180eb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class RefreshFlowRequest + { + public AuthSessionId? SessionId { get; init; } + public string? RefreshToken { get; init; } + public required DeviceContext Device { get; init; } + public DateTimeOffset Now { 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..7c1f26eb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs @@ -0,0 +1,40 @@ +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 RefreshToken? 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, + RefreshToken? 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..e4352d05 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum RefreshStrategy + { + NotSupported, + SessionOnly, // PureOpaque + TokenOnly, // PureJwt + TokenWithSessionCheck, // SemiHybrid + SessionAndToken // 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..dc5891cf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs @@ -0,0 +1,18 @@ +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, + + /// + /// Refresh token store'a yazılmaz. + /// Rotation gibi özel akışlarda, + /// caller tarafından kontrol edilir. + /// + DoNotPersist + } +} 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..6b9375de --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record RefreshTokenValidationContext + { + public string? TenantId { get; init; } + public string RefreshToken { get; init; } = default!; + public DateTimeOffset Now { get; init; } + + // For Hybrid & Advanced + public required DeviceContext Device { get; init; } + public AuthSessionId? ExpectedSessionId { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs index f0045753..704398a6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed record AuthStateSnapshot { + // It's not UserId type public string? UserId { get; init; } public string? TenantId { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs index a46570e3..08890b7a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs @@ -6,21 +6,21 @@ namespace CodeBeam.UltimateAuth.Core.Contracts /// Represents the context in which a session is issued /// (login, refresh, reauthentication). /// - public sealed class AuthenticatedSessionContext + public sealed class AuthenticatedSessionContext { public string? TenantId { get; init; } - public required TUserId UserId { get; init; } - public DeviceInfo DeviceInfo { 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 SessionMetadata Metadata { get; init; } + public required SessionMetadata Metadata { get; init; } /// /// Optional chain identifier. /// If null, a new chain will be created. /// If provided, session will be issued under the existing chain. /// - public ChainId? ChainId { get; init; } + public SessionChainId? ChainId { get; init; } /// /// Indicates that authentication has already been completed. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs index cc2f0f82..0d1622de 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs @@ -5,12 +5,12 @@ namespace CodeBeam.UltimateAuth.Core.Contracts /// /// Represents the result of a session issuance operation. /// - public sealed class IssuedSession + public sealed class IssuedSession { /// /// The issued domain session. /// - public required ISession Session { get; init; } + public required ISession Session { get; init; } /// /// Opaque session identifier returned to the client. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs index 91896640..ece8d802 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs @@ -2,32 +2,32 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { - public sealed record ResolvedRefreshSession + public sealed record ResolvedRefreshSession { public bool IsValid { get; init; } public bool IsReuseDetected { get; init; } - public ISession? Session { get; init; } - public ISessionChain? Chain { get; init; } + public ISession? Session { get; init; } + public ISessionChain? Chain { get; init; } private ResolvedRefreshSession() { } - public static ResolvedRefreshSession Invalid() + public static ResolvedRefreshSession Invalid() => new() { IsValid = false }; - public static ResolvedRefreshSession Reused() + public static ResolvedRefreshSession Reused() => new() { IsValid = false, IsReuseDetected = true }; - public static ResolvedRefreshSession Valid( - ISession session, - ISessionChain chain) + public static ResolvedRefreshSession Valid( + ISession session, + ISessionChain chain) => new() { IsValid = true, diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs index 8edd1680..d06a8542 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -6,25 +6,22 @@ public sealed record SessionRefreshResult { public SessionRefreshStatus Status { get; init; } - public PrimaryToken? PrimaryToken { get; init; } - - public RefreshToken? RefreshToken { 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( - PrimaryToken primaryToken, - RefreshToken? refreshToken = null, + AuthSessionId sessionId, bool didTouch = false) => new() { Status = SessionRefreshStatus.Success, - PrimaryToken = primaryToken, - RefreshToken = refreshToken, + SessionId = sessionId, DidTouch = didTouch }; @@ -46,7 +43,5 @@ public static SessionRefreshResult Failed() Status = SessionRefreshStatus.Failed }; - public bool RequiresReauth => Status == SessionRefreshStatus.ReauthRequired; - } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs index cb43f4e4..8517fccb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs @@ -16,25 +16,25 @@ namespace CodeBeam.UltimateAuth.Core.Contracts /// token services, event emitters, logging pipelines, or application-level /// consumers — can easily access all updated authentication structures. /// - public sealed class SessionResult + public sealed class SessionResult { /// /// Gets the active session produced by the operation. /// This is the newest session and the one that should be used when issuing tokens. /// - public required ISession Session { get; init; } + public required ISession Session { get; init; } /// /// Gets the session chain associated with the session. /// The chain may be newly created (login) or updated (session rotation). /// - public required ISessionChain Chain { get; init; } + public required ISessionChain Chain { get; init; } /// /// Gets the user's session root. /// This structure may be updated when new chains are added or when security /// properties change. /// - public required ISessionRoot Root { get; init; } + public required ISessionRoot Root { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs index 874e4b7e..0d23664c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -2,14 +2,14 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { - public sealed record SessionRotationContext + public sealed record SessionRotationContext { public string? TenantId { get; init; } public AuthSessionId CurrentSessionId { get; init; } - public TUserId UserId { get; init; } + public UserKey UserKey { get; init; } public DateTimeOffset Now { get; init; } - public DeviceInfo Device { get; init; } - public ClaimsSnapshot Claims { get; init; } - public SessionMetadata Metadata { get; init; } + public required DeviceContext Device { get; init; } + public ClaimsSnapshot? Claims { get; init; } + public required SessionMetadata Metadata { get; init; } = SessionMetadata.Empty; } } 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..a16d81ca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs @@ -0,0 +1,18 @@ +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 index 78910d49..76b089a5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs @@ -6,12 +6,12 @@ namespace CodeBeam.UltimateAuth.Core.Contracts /// Context information required by the session store when /// creating or rotating sessions. /// - public sealed class SessionStoreContext + public sealed class SessionStoreContext { /// /// The authenticated user identifier. /// - public required TUserId UserId { get; init; } + public required UserKey UserKey { get; init; } /// /// The tenant identifier, if multi-tenancy is enabled. @@ -22,7 +22,7 @@ public sealed class SessionStoreContext /// Optional chain identifier. /// If null, a new chain should be created. /// - public ChainId? ChainId { get; init; } + public SessionChainId? ChainId { get; init; } /// /// Indicates whether the session is metadata-only @@ -38,6 +38,6 @@ public sealed class SessionStoreContext /// /// Optional device or client identifier. /// - public DeviceInfo? DeviceInfo { get; init; } + 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..f7f42262 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum SessionTouchMode + { + /// + /// Touch only if store policy allows (interval, throttling, etc.) + /// + IfNeeded, + + /// + /// Always update session activity, ignoring store heuristics. + /// + Force + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs index 85a39968..bcae9016 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs @@ -7,6 +7,6 @@ public sealed record SessionValidationContext public string? TenantId { get; init; } public AuthSessionId SessionId { get; init; } public DateTimeOffset Now { get; init; } - public DeviceInfo Device { 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 index f862000f..d760b286 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -2,49 +2,65 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { - public sealed class SessionValidationResult + public sealed class SessionValidationResult { - public string? TenantId { get; } - public SessionState State { get; } - public ISession? Session { get; } - public ISessionChain? Chain { get; } - public ISessionRoot? Root { get; } + public string? TenantId { get; init; } - private SessionValidationResult( - string? tenantId, - SessionState state, - ISession? session, - ISessionChain? chain, - ISessionRoot? root) - { - TenantId = tenantId; - State = state; - Session = session; - Chain = chain; - Root = root; - } + 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 ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; public bool IsValid => State == SessionState.Active; - public static SessionValidationResult Active( + private SessionValidationResult() { } + + public static SessionValidationResult Active( string? tenantId, - ISession session, - ISessionChain chain, - ISessionRoot root) - => new( - tenantId, - SessionState.Active, - session, - chain, - root); - - public static SessionValidationResult Invalid( - SessionState state) - => new( - tenantId: null, - state, - session: null, - chain: null, - root: null); + UserKey? userId, + AuthSessionId sessionId, + SessionChainId chainId, + SessionRootId rootId, + ClaimsSnapshot claims, + DeviceId? boundDeviceId = null) + => new() + { + TenantId = tenantId, + State = SessionState.Active, + UserKey = userId, + SessionId = sessionId, + ChainId = chainId, + RootId = rootId, + Claims = claims, + BoundDeviceId = boundDeviceId + }; + + public static SessionValidationResult Invalid( + SessionState state, + UserKey? userId = null, + AuthSessionId? sessionId = null, + SessionChainId? chainId = null, + SessionRootId? rootId = null, + DeviceId? boundDeviceId = null) + => new() + { + TenantId = null, + State = state, + UserKey = userId, + SessionId = sessionId, + ChainId = chainId, + RootId = rootId, + Claims = ClaimsSnapshot.Empty, + BoundDeviceId = boundDeviceId + }; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs index 59f5de09..cb43d693 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs @@ -14,7 +14,7 @@ private PrimaryToken(PrimaryTokenKind kind, string value) } public static PrimaryToken FromSession(AuthSessionId sessionId) - => new(PrimaryTokenKind.Session, sessionId.Value); + => 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/RefreshTokenRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs index d4453a8a..d60ea275 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs @@ -1,7 +1,11 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; -public sealed record RefreshTokenRotationContext +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..e565b329 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +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 string? TenantId { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs index b0efbe26..b1c50d0d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs @@ -1,10 +1,14 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +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 RefreshToken? RefreshToken { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs deleted file mode 100644 index 93f5a44a..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record RefreshTokenValidationContext - { - public string TenantId { get; init; } = default!; - public AuthSessionId SessionId { get; init; } - public string ProvidedRefreshToken { get; init; } = default!; - public DateTimeOffset Now { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs index 3c0699ff..e9423502 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs @@ -2,63 +2,62 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { - public sealed record RefreshTokenValidationResult + public sealed record RefreshTokenValidationResult { public bool IsValid { get; init; } - public bool IsReuseDetected { get; init; } - public string? TenantId { get; init; } - public TUserId? UserId { get; init; } + public string? TokenHash { get; init; } + public string? TenantId { get; init; } + public UserKey? UserKey { get; init; } public AuthSessionId? SessionId { get; init; } - - public ChainId? ChainId { get; init; } + public SessionChainId? ChainId { get; init; } public DateTimeOffset? ExpiresAt { get; init; } private RefreshTokenValidationResult() { } - // ---------------------------- - // FACTORIES - // ---------------------------- - - public static RefreshTokenValidationResult Invalid() + public static RefreshTokenValidationResult Invalid() => new() { IsValid = false, IsReuseDetected = false }; - public static RefreshTokenValidationResult ReuseDetected( - string? tenantId = null, - AuthSessionId? sessionId = null, - ChainId? chainId = null, - TUserId? userId = default) + public static RefreshTokenValidationResult ReuseDetected( + string? tenantId = null, + AuthSessionId? sessionId = null, + string? tokenHash = null, + SessionChainId? chainId = null, + UserKey? userKey = default) => new() { IsValid = false, IsReuseDetected = true, TenantId = tenantId, SessionId = sessionId, + TokenHash = tokenHash, ChainId = chainId, - UserId = userId + UserKey = userKey, }; - public static RefreshTokenValidationResult Valid( - string? tenantId, - TUserId userId, - AuthSessionId sessionId, - ChainId? chainId = null) + public static RefreshTokenValidationResult Valid( + string? tenantId, + UserKey userKey, + AuthSessionId sessionId, + string? tokenHash, + SessionChainId? chainId = null) => new() { IsValid = true, IsReuseDetected = false, TenantId = tenantId, - UserId = userId, + UserKey = userKey, SessionId = sessionId, - ChainId = chainId + ChainId = chainId, + TokenHash = tokenHash }; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs index fde5c8ac..f070cd12 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs @@ -1,11 +1,14 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed record TokenIssuanceContext { - public string UserId { get; init; } = default!; + public required UserKey UserKey { get; init; } public string? TenantId { get; init; } public IReadOnlyDictionary Claims { get; set; } = new Dictionary(); - public string? SessionId { get; init; } + public AuthSessionId? SessionId { get; init; } + public SessionChainId? ChainId { get; init; } public DateTimeOffset IssuedAt { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs index c4cb22fd..d7428ae7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs @@ -2,10 +2,10 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { - public sealed record TokenIssueContext + public sealed record TokenIssueContext { public string? TenantId { get; init; } - public ISession Session { get; init; } = default!; + public ISession Session { get; init; } = default!; public DateTimeOffset At { get; init; } } } 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..e345dfb9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -0,0 +1,27 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public sealed class DeviceContext + { + public DeviceId? DeviceId { get; init; } + + public bool HasDeviceId => DeviceId is not null; + + private DeviceContext(DeviceId? deviceId) + { + DeviceId = deviceId; + } + + public static DeviceContext Anonymous() + => new(null); + + public static DeviceContext FromDeviceId(DeviceId deviceId) + => new(deviceId); + + // 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. + // IP, Geo, Fingerprint, Platform, UA 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..7da55a17 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs @@ -0,0 +1,69 @@ +using System.Security; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +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/Hub/HubCredentials.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs new file mode 100644 index 00000000..0c059345 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs @@ -0,0 +1,11 @@ +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/HubFlowArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs new file mode 100644 index 00000000..98704a10 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs @@ -0,0 +1,33 @@ +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 string? TenantId { get; } + public string? ReturnUrl { get; } + + public HubFlowPayload Payload { get; } + + public HubFlowArtifact( + HubSessionId hubSessionId, + HubFlowType flowType, + UAuthClientProfile clientProfile, + string? tenantId, + string? returnUrl, + HubFlowPayload payload, + DateTimeOffset expiresAt) + : base(AuthArtifactType.HubFlow, expiresAt, maxAttempts: 1) + { + HubSessionId = hubSessionId; + FlowType = flowType; + ClientProfile = clientProfile; + TenantId = tenantId; + ReturnUrl = returnUrl; + Payload = payload; + } +} 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..c9833737 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs @@ -0,0 +1,22 @@ +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; + } +} 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..344f1a6c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs @@ -0,0 +1,18 @@ +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 bool IsActive { get; init; } + public bool IsExpired { get; init; } + public bool IsCompleted { get; init; } + public bool Exists { get; init; } + } + +} 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..7f34bcff --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs @@ -0,0 +1,23 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +// TODO: Bind id with IP and UA +public readonly record struct HubSessionId(string Value) +{ + public static HubSessionId New() => new(Guid.NewGuid().ToString("N")); + + public override string ToString() => Value; + + 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; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs new file mode 100644 index 00000000..a0c69a46 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs @@ -0,0 +1,34 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public abstract class AuthArtifact +{ + protected AuthArtifact(AuthArtifactType type, DateTimeOffset expiresAt, int maxAttempts) + { + Type = type; + ExpiresAt = expiresAt; + MaxAttempts = maxAttempts; + } + + public AuthArtifactType Type { get; } + + public DateTimeOffset ExpiresAt { get; internal set; } + + public int MaxAttempts { get; } + + public int AttemptCount { get; private set; } + public bool IsCompleted { get; private set; } + + public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt; + + public bool CanAttempt() => AttemptCount < MaxAttempts; + + public void RegisterAttempt() + { + AttemptCount++; + } + + public void MarkCompleted() + { + IsCompleted = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs new file mode 100644 index 00000000..70512e67 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifactType.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthArtifactType +{ + PkceAuthorizationCode, + HubFlow, + HubLogin, + MfaChallenge, + PasswordReset, + MagicLink, + OAuthState, + Custom = 1000 +} 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..02751dcb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs @@ -0,0 +1,18 @@ +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, + int maxAttempts = 3) + : base(AuthArtifactType.HubLogin, expiresAt, maxAttempts) + { + AuthorizationCode = authorizationCode; + CodeVerifier = codeVerifier; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index e6261ca0..8663562d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -1,69 +1,35 @@ namespace CodeBeam.UltimateAuth.Core.Domain { - /// - /// Represents a strongly typed identifier for an authentication session. - /// Wraps a value and provides type safety across the UltimateAuth session management system. - /// - public readonly struct AuthSessionId : IEquatable + // AuthSessionId is a opaque token, because it's more sensitive data. SessionChainId and SessionRootId are Guid. + public readonly record struct AuthSessionId { - // TODO: Change this private - public AuthSessionId(string value) - { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("SessionId cannot be empty.", nameof(value)); + public string Value { get; } + private AuthSessionId(string value) + { Value = value; } - public static bool TryCreate(string raw, out AuthSessionId sessionId) + public static bool TryCreate(string raw, out AuthSessionId id) { if (string.IsNullOrWhiteSpace(raw)) { - sessionId = default; + id = default; + return false; + } + + if (raw.Length < 32) + { + id = default; return false; } - sessionId = new AuthSessionId(raw); + id = new AuthSessionId(raw); return true; } - /// - /// Gets the underlying GUID value of the session identifier. - /// - public string Value { get; } - - public static AuthSessionId From(string value) => new(value); - - /// - /// Determines whether the specified is equal to the current instance. - /// - /// The session identifier to compare with. - /// true if the identifiers match; otherwise, false. - public bool Equals(AuthSessionId other) => Value.Equals(other.Value); - - /// - /// Determines whether the specified object is equal to the current session identifier. - /// - /// The object to compare with. - /// true if the object is an with the same value. - public override bool Equals(object? obj) => obj is AuthSessionId other && Equals(other); - - /// - /// Returns a hash code based on the underlying GUID value. - /// - public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); - - /// - /// Returns the string representation of the underlying GUID value. - /// - /// The GUID as a string. public override string ToString() => Value; - /// - /// Converts the to its underlying . - /// - /// The session identifier. - /// The underlying GUID value. public static implicit operator string(AuthSessionId id) => id.Value; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs deleted file mode 100644 index 486c80cd..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - /// - /// Represents a strongly typed identifier for a session chain. - /// A session chain groups multiple rotated sessions belonging to the same - /// device or application family, providing type safety across the UltimateAuth session system. - /// - public readonly struct ChainId : IEquatable - { - /// - /// Initializes a new with the specified GUID value. - /// - /// The underlying GUID representing the chain identifier. - public ChainId(Guid value) - { - Value = value; - } - - /// - /// Gets the underlying GUID value of the chain identifier. - /// - public Guid Value { get; } - - /// - /// Generates a new chain identifier using a newly created GUID. - /// - /// A new instance. - public static ChainId New() => new ChainId(Guid.NewGuid()); - - public static ChainId From(Guid value) => new(value); - - /// - /// Determines whether the specified is equal to the current instance. - /// - /// The chain identifier to compare with. - /// true if both identifiers represent the same chain. - public bool Equals(ChainId other) => Value.Equals(other.Value); - - /// - /// Determines whether the specified object is equal to the current chain identifier. - /// - /// The object to compare with. - /// true if the object is a with the same value. - public override bool Equals(object? obj) => obj is ChainId other && Equals(other); - - public static bool operator ==(ChainId left, ChainId right) => left.Equals(right); - - public static bool operator !=(ChainId left, ChainId right) => !left.Equals(right); - - /// - /// Returns a hash code based on the underlying GUID value. - /// - public override int GetHashCode() => Value.GetHashCode(); - - /// - /// Returns the string representation of the underlying GUID value. - /// - /// The GUID as a string. - public override string ToString() => Value.ToString(); - - /// - /// Converts the to its underlying value. - /// - /// The chain identifier. - /// The underlying GUID value. - public static implicit operator Guid(ChainId id) => id.Value; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs deleted file mode 100644 index 969b474b..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs +++ /dev/null @@ -1,106 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - /// - /// Represents metadata describing the device or client environment initiating - /// an authentication session. Used for security analytics, session management, - /// fraud detection, and device-specific login policies. - /// - public sealed class DeviceInfo - { - // TODO: Implement DeviceId and makes it first-class citizen in security policies. - /// - /// Gets the unique identifier for the device. - /// No session should be created without a device id. - /// - public string DeviceId { get; init; } = default!; - - /// - /// Gets the high-level platform identifier, such as web, mobile, - /// tablet or iot. - /// Used for platform-based session limits and analytics. - /// - public string? Platform { get; init; } - - /// - /// Gets the operating system of the client device, such as iOS 17, - /// Android 14, Windows 11, or macOS Sonoma. - /// - public string? OperatingSystem { get; init; } - - /// - /// Gets the browser name and version when the client is web-based, - /// such as Edge, Chrome, Safari, or Firefox. - /// May be null for native applications. - /// - public string? Browser { get; init; } - - /// - /// Gets the IP address of the client device. - /// Used for IP-binding, geolocation checks, and anomaly detection. - /// - public string? IpAddress { get; init; } - - /// - /// Gets the raw user-agent string for web clients. - /// Used when deeper parsing of browser or device details is needed. - /// - public string? UserAgent { get; init; } - - /// - /// Gets a device fingerprint or unique client identifier if provided by the - /// application. Useful for advanced session policies or fraud analysis. - /// - public string? Fingerprint { get; init; } - - /// - /// Indicates whether the device is considered trusted by the user or system. - /// Applications may update this value when implementing trusted-device flows. - /// - public bool? IsTrusted { get; init; } - - /// - /// Gets optional custom metadata supplied by the application. - /// Allows additional device attributes not covered by standard fields. - /// - public Dictionary? Custom { get; init; } - - public static DeviceInfo Unknown { get; } = new() - { - DeviceId = "unknown", - Platform = null, - Browser = null, - IpAddress = null, - UserAgent = null, - IsTrusted = null - }; - - // TODO: Empty may not be good approach, make strict security here - public static DeviceInfo Empty { get; } = new() - { - DeviceId = "", - Platform = null, - Browser = null, - IpAddress = null, - UserAgent = null, - IsTrusted = null - }; - - /// - /// Determines whether the current device information matches the specified device information based on device - /// identifiers. - /// - /// The device information to compare with the current instance. Cannot be null. - /// true if the device identifiers are equal; otherwise, false. - public bool Matches(DeviceInfo other) - { - if (other is null) - return false; - - if (DeviceId != other.DeviceId) - return false; - - // TODO: UA / IP drift policy - return true; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs index 42da3353..ac2f975a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -1,11 +1,13 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Domain { /// /// Represents a single authentication session belonging to a user. /// Sessions are immutable, security-critical units used for validation, /// sliding expiration, revocation, and device analytics. /// - public interface ISession + public interface ISession { /// /// Gets the unique identifier of the session. @@ -17,9 +19,9 @@ public interface ISession /// /// Gets the identifier of the user who owns this session. /// - TUserId UserId { get; } + UserKey UserKey { get; } - ChainId ChainId { get; } + SessionChainId ChainId { get; } /// /// Gets the timestamp when this session was originally created. @@ -58,7 +60,7 @@ public interface ISession /// Gets metadata describing the client device that created the session. /// Includes platform, OS, IP address, fingerprint, and more. /// - DeviceInfo Device { get; } + DeviceContext Device { get; } ClaimsSnapshot Claims { get; } @@ -75,8 +77,10 @@ public interface ISession /// The evaluated of this session. SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout); - ISession Touch(DateTimeOffset now); - ISession Revoke(DateTimeOffset at); + ISession Touch(DateTimeOffset now); + ISession Revoke(DateTimeOffset at); + + ISession WithChain(SessionChainId chainId); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs index c7faf4a7..7659f475 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs @@ -5,12 +5,14 @@ /// A chain groups all rotated sessions belonging to a single logical login /// (e.g., a browser instance, mobile app installation, or device fingerprint). /// - public interface ISessionChain + public interface ISessionChain { /// /// Gets the unique identifier of the session chain. /// - ChainId ChainId { get; } + SessionChainId ChainId { get; } + + SessionRootId RootId { get; } string? TenantId { get; } @@ -18,7 +20,7 @@ public interface ISessionChain /// Gets the identifier of the user who owns this chain. /// Each chain represents one device/login family for this user. /// - TUserId UserId { get; } + UserKey UserKey { get; } /// /// Gets the number of refresh token rotations performed within this chain. @@ -56,9 +58,9 @@ public interface ISessionChain /// DateTimeOffset? RevokedAt { get; } - ISessionChain AttachSession(AuthSessionId sessionId); - ISessionChain RotateSession(AuthSessionId sessionId); - ISessionChain Revoke(DateTimeOffset at); + ISessionChain AttachSession(AuthSessionId sessionId); + ISessionChain RotateSession(AuthSessionId sessionId); + ISessionChain Revoke(DateTimeOffset at); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs index c51ba8e3..b839292a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs @@ -5,8 +5,10 @@ /// A session root is tenant-scoped and acts as the authoritative security boundary, /// controlling global revocation, security versioning, and device/login families. /// - public interface ISessionRoot + public interface ISessionRoot { + SessionRootId RootId { get; } + /// /// Gets the tenant identifier associated with this session root. /// Used to isolate authentication domains in multi-tenant systems. @@ -17,7 +19,7 @@ public interface ISessionRoot /// Gets the identifier of the user who owns this session root. /// Each user has one root per tenant. /// - TUserId UserId { get; } + UserKey UserKey { get; } /// /// Gets a value indicating whether the entire session root is revoked. @@ -43,7 +45,7 @@ public interface ISessionRoot /// Each chain represents a device or login-family (browser instance, mobile app, etc.). /// The root is immutable; modifications must go through SessionService or SessionStore. /// - IReadOnlyList> Chains { get; } + IReadOnlyList Chains { get; } /// /// Gets the timestamp when this root structure was last updated. @@ -51,8 +53,8 @@ public interface ISessionRoot /// DateTimeOffset LastUpdatedAt { get; } - ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at); + ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at); - ISessionRoot Revoke(DateTimeOffset at); + ISessionRoot Revoke(DateTimeOffset at); } } 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..5c253e65 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs @@ -0,0 +1,33 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public readonly record struct SessionChainId(Guid Value) + { + 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 override string ToString() => Value.ToString("N"); + } +} 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..68d595a2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs @@ -0,0 +1,26 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public readonly record struct SessionRootId(Guid Value) + { + 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 override string ToString() => Value.ToString("N"); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index d7f5c5a4..78786ef1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,39 +1,41 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Domain { - public sealed class UAuthSession : ISession + public sealed class UAuthSession : ISession { public AuthSessionId SessionId { get; } public string? TenantId { get; } - public TUserId UserId { get; } - public ChainId ChainId { get; } + public UserKey UserKey { get; } + public SessionChainId ChainId { get; } public DateTimeOffset CreatedAt { get; } public DateTimeOffset ExpiresAt { get; } public DateTimeOffset? LastSeenAt { get; } public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersionAtCreation { get; } - public DeviceInfo Device { get; } + public DeviceContext Device { get; } public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } private UAuthSession( AuthSessionId sessionId, string? tenantId, - TUserId userId, - ChainId chainId, + UserKey userKey, + SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, DateTimeOffset? lastSeenAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, - DeviceInfo device, + DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata) { SessionId = sessionId; TenantId = tenantId; - UserId = userId; + UserKey = userKey; ChainId = chainId; CreatedAt = createdAt; ExpiresAt = expiresAt; @@ -46,21 +48,21 @@ private UAuthSession( Metadata = metadata; } - public static UAuthSession Create( + public static UAuthSession Create( AuthSessionId sessionId, string? tenantId, - TUserId userId, - ChainId chainId, + UserKey userKey, + SessionChainId chainId, DateTimeOffset now, DateTimeOffset expiresAt, - DeviceInfo device, + DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata) { return new( sessionId, tenantId, - userId, + userKey, chainId, createdAt: now, expiresAt: expiresAt, @@ -74,15 +76,15 @@ public static UAuthSession Create( ); } - public UAuthSession WithSecurityVersion(long version) + public UAuthSession WithSecurityVersion(long version) { if (SecurityVersionAtCreation == version) return this; - return new UAuthSession( + return new UAuthSession( SessionId, TenantId, - UserId, + UserKey, ChainId, CreatedAt, ExpiresAt, @@ -96,12 +98,12 @@ public UAuthSession WithSecurityVersion(long version) ); } - public ISession Touch(DateTimeOffset at) + public ISession Touch(DateTimeOffset at) { - return new UAuthSession( + return new UAuthSession( SessionId, TenantId, - UserId, + UserKey, ChainId, CreatedAt, ExpiresAt, @@ -115,14 +117,14 @@ public ISession Touch(DateTimeOffset at) ); } - public ISession Revoke(DateTimeOffset at) + public ISession Revoke(DateTimeOffset at) { if (IsRevoked) return this; - return new UAuthSession( + return new UAuthSession( SessionId, TenantId, - UserId, + UserKey, ChainId, CreatedAt, ExpiresAt, @@ -136,25 +138,25 @@ public ISession Revoke(DateTimeOffset at) ); } - internal static UAuthSession FromProjection( - AuthSessionId sessionId, - string? tenantId, - TUserId userId, - ChainId chainId, - DateTimeOffset createdAt, - DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, - bool isRevoked, - DateTimeOffset? revokedAt, - long securityVersionAtCreation, - DeviceInfo device, - ClaimsSnapshot claims, - SessionMetadata metadata) + internal static UAuthSession FromProjection( + AuthSessionId sessionId, + string? tenantId, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + DateTimeOffset? lastSeenAt, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + DeviceContext device, + ClaimsSnapshot claims, + SessionMetadata metadata) { - return new UAuthSession( + return new UAuthSession( sessionId, tenantId, - userId, + userKey, chainId, createdAt, expiresAt, @@ -181,6 +183,29 @@ public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) return SessionState.Active; } + + public ISession WithChain(SessionChainId chainId) + { + if (!ChainId.IsUnassigned) + throw new InvalidOperationException("Chain already assigned."); + + return new UAuthSession( + sessionId: SessionId, + tenantId: TenantId, + userKey: UserKey, + chainId: chainId, + createdAt: CreatedAt, + expiresAt: ExpiresAt, + lastSeenAt: LastSeenAt, + isRevoked: IsRevoked, + revokedAt: RevokedAt, + securityVersionAtCreation: SecurityVersionAtCreation, + device: Device, + claims: Claims, + metadata: Metadata + ); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 91ecbce1..9403f95e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,10 +1,11 @@ namespace CodeBeam.UltimateAuth.Core.Domain { - public sealed class UAuthSessionChain : ISessionChain + public sealed class UAuthSessionChain : ISessionChain { - public ChainId ChainId { get; } + public SessionChainId ChainId { get; } + public SessionRootId RootId { get; } public string? TenantId { get; } - public TUserId UserId { get; } + public UserKey UserKey { get; } public int RotationCount { get; } public long SecurityVersionAtCreation { get; } public ClaimsSnapshot ClaimsSnapshot { get; } @@ -13,9 +14,10 @@ public sealed class UAuthSessionChain : ISessionChain public DateTimeOffset? RevokedAt { get; } private UAuthSessionChain( - ChainId chainId, + SessionChainId chainId, + SessionRootId rootId, string? tenantId, - TUserId userId, + UserKey userKey, int rotationCount, long securityVersionAtCreation, ClaimsSnapshot claimsSnapshot, @@ -24,8 +26,9 @@ private UAuthSessionChain( DateTimeOffset? revokedAt) { ChainId = chainId; + RootId = rootId; TenantId = tenantId; - UserId = userId; + UserKey = userKey; RotationCount = rotationCount; SecurityVersionAtCreation = securityVersionAtCreation; ClaimsSnapshot = claimsSnapshot; @@ -34,17 +37,19 @@ private UAuthSessionChain( RevokedAt = revokedAt; } - public static UAuthSessionChain Create( - ChainId chainId, + public static UAuthSessionChain Create( + SessionChainId chainId, + SessionRootId rootId, string? tenantId, - TUserId userId, + UserKey userKey, long securityVersion, ClaimsSnapshot claimsSnapshot) { - return new UAuthSessionChain( + return new UAuthSessionChain( chainId, + rootId, tenantId, - userId, + userKey, rotationCount: 0, securityVersionAtCreation: securityVersion, claimsSnapshot: claimsSnapshot, @@ -54,15 +59,16 @@ public static UAuthSessionChain Create( ); } - public ISessionChain AttachSession(AuthSessionId sessionId) + public ISessionChain AttachSession(AuthSessionId sessionId) { if (IsRevoked) return this; - return new UAuthSessionChain( + return new UAuthSessionChain( ChainId, + RootId, TenantId, - UserId, + UserKey, RotationCount, // Unchanged on first attach SecurityVersionAtCreation, ClaimsSnapshot, @@ -72,15 +78,16 @@ public ISessionChain AttachSession(AuthSessionId sessionId) ); } - public ISessionChain RotateSession(AuthSessionId sessionId) + public ISessionChain RotateSession(AuthSessionId sessionId) { if (IsRevoked) return this; - return new UAuthSessionChain( + return new UAuthSessionChain( ChainId, + RootId, TenantId, - UserId, + UserKey, RotationCount + 1, SecurityVersionAtCreation, ClaimsSnapshot, @@ -90,15 +97,16 @@ public ISessionChain RotateSession(AuthSessionId sessionId) ); } - public ISessionChain Revoke(DateTimeOffset at) + public ISessionChain Revoke(DateTimeOffset at) { if (IsRevoked) return this; - return new UAuthSessionChain( + return new UAuthSessionChain( ChainId, + RootId, TenantId, - UserId, + UserKey, RotationCount, SecurityVersionAtCreation, ClaimsSnapshot, @@ -108,21 +116,23 @@ public ISessionChain Revoke(DateTimeOffset at) ); } - internal static UAuthSessionChain FromProjection( - ChainId chainId, - string? tenantId, - TUserId userId, - int rotationCount, - long securityVersionAtCreation, - ClaimsSnapshot claimsSnapshot, - AuthSessionId? activeSessionId, - bool isRevoked, - DateTimeOffset? revokedAt) + internal static UAuthSessionChain FromProjection( + SessionChainId chainId, + SessionRootId rootId, + string? tenantId, + UserKey userKey, + int rotationCount, + long securityVersionAtCreation, + ClaimsSnapshot claimsSnapshot, + AuthSessionId? activeSessionId, + bool isRevoked, + DateTimeOffset? revokedAt) { - return new UAuthSessionChain( + return new UAuthSessionChain( chainId, + rootId, tenantId, - userId, + userKey, rotationCount, securityVersionAtCreation, claimsSnapshot, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index ae41b27a..0153210f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -1,26 +1,29 @@ namespace CodeBeam.UltimateAuth.Core.Domain { - public sealed class UAuthSessionRoot : ISessionRoot + public sealed class UAuthSessionRoot : ISessionRoot { - public TUserId UserId { get; } + public SessionRootId RootId { get; } + public UserKey UserKey { get; } public string? TenantId { get; } public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersion { get; } - public IReadOnlyList> Chains { get; } + public IReadOnlyList Chains { get; } public DateTimeOffset LastUpdatedAt { get; } private UAuthSessionRoot( + SessionRootId rootId, string? tenantId, - TUserId userId, + UserKey userKey, bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, - IReadOnlyList> chains, + IReadOnlyList chains, DateTimeOffset lastUpdatedAt) { + RootId = rootId; TenantId = tenantId; - UserId = userId; + UserKey = userKey; IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersion = securityVersion; @@ -28,30 +31,32 @@ private UAuthSessionRoot( LastUpdatedAt = lastUpdatedAt; } - public static ISessionRoot Create( + public static ISessionRoot Create( string? tenantId, - TUserId userId, + UserKey userKey, DateTimeOffset issuedAt) { - return new UAuthSessionRoot( + return new UAuthSessionRoot( + SessionRootId.New(), tenantId, - userId, + userKey, isRevoked: false, revokedAt: null, securityVersion: 0, - chains: Array.Empty>(), + chains: Array.Empty(), lastUpdatedAt: issuedAt ); } - public ISessionRoot Revoke(DateTimeOffset at) + public ISessionRoot Revoke(DateTimeOffset at) { if (IsRevoked) return this; - return new UAuthSessionRoot( + return new UAuthSessionRoot( + RootId, TenantId, - UserId, + UserKey, isRevoked: true, revokedAt: at, securityVersion: SecurityVersion, @@ -60,14 +65,15 @@ public ISessionRoot Revoke(DateTimeOffset at) ); } - public ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at) + public ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at) { if (IsRevoked) return this; - return new UAuthSessionRoot( + return new UAuthSessionRoot( + RootId, TenantId, - UserId, + UserKey, IsRevoked, RevokedAt, SecurityVersion, @@ -76,18 +82,20 @@ public ISessionRoot AttachChain(ISessionChain chain, DateTimeO ); } - internal static UAuthSessionRoot FromProjection( - string? tenantId, - TUserId userId, - bool isRevoked, - DateTimeOffset? revokedAt, - long securityVersion, - IReadOnlyList> chains, - DateTimeOffset lastUpdatedAt) + internal static UAuthSessionRoot FromProjection( + SessionRootId rootId, + string? tenantId, + UserKey userKey, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersion, + IReadOnlyList chains, + DateTimeOffset lastUpdatedAt) { - return new UAuthSessionRoot( + return new UAuthSessionRoot( + rootId, tenantId, - userId, + userKey, isRevoked, revokedAt, securityVersion, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs index 00197209..f1592b64 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs @@ -6,16 +6,16 @@ namespace CodeBeam.UltimateAuth.Core.Domain /// Represents a persisted refresh token bound to a session. /// Stored as a hashed value for security reasons. /// - public sealed record StoredRefreshToken + public sealed record StoredRefreshToken { public string TokenHash { get; init; } = default!; public string? TenantId { get; init; } - public TUserId UserId { get; init; } = default!; + public required UserKey UserKey { get; init; } public AuthSessionId SessionId { get; init; } = default!; - public ChainId? ChainId { get; init; } + public SessionChainId? ChainId { get; init; } public DateTimeOffset IssuedAt { get; init; } public DateTimeOffset ExpiresAt { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs deleted file mode 100644 index 962885f9..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - /// - /// Strongly typed identifier for a user. - /// Default user id implementation for UltimateAuth. - /// - public readonly record struct UserId(string Value) - { - public override string ToString() => Value; - - public static UserId New() => new(Guid.NewGuid().ToString("N")); - - public static implicit operator string(UserId id) => id.Value; - public static implicit operator UserId(string value) => new(value); - } -} 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..f696d21b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs @@ -0,0 +1,39 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public readonly record struct UserKey + { + 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 override string ToString() => Value; + + public static implicit operator string(UserKey key) => key.Value; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs index 22386973..a62b507c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs @@ -4,10 +4,10 @@ namespace CodeBeam.UltimateAuth.Core.Errors { public abstract class UAuthChainException : UAuthDomainException { - public ChainId ChainId { get; } + public SessionChainId ChainId { get; } protected UAuthChainException( - ChainId chainId, + SessionChainId chainId, string message) : base(message) { diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs index 758b7ef0..91d0bafa 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Errors { public sealed class UAuthSessionChainNotFoundException : UAuthChainException { - public UAuthSessionChainNotFoundException(ChainId chainId) + public UAuthSessionChainNotFoundException(SessionChainId chainId) : base(chainId, $"Session chain '{chainId}' was not found.") { } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs index c755b931..bd880af9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs @@ -4,9 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Errors { public sealed class UAuthSessionChainRevokedException : UAuthChainException { - public ChainId ChainId { get; } + public SessionChainId ChainId { get; } - public UAuthSessionChainRevokedException(ChainId chainId) + public UAuthSessionChainRevokedException(SessionChainId chainId) : base(chainId, $"Session chain '{chainId}' has been revoked.") { } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs index bb8660f1..07e425e1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Core.Errors { diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs index a989b867..544b2eba 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs @@ -27,7 +27,7 @@ public sealed class SessionCreatedContext : IAuthEventContext /// /// Gets the identifier of the session chain to which this session belongs. /// - public ChainId ChainId { get; } + public SessionChainId ChainId { get; } /// /// Gets the timestamp on which the session was created. @@ -37,7 +37,7 @@ public sealed class SessionCreatedContext : IAuthEventContext /// /// Initializes a new instance of the class. /// - public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, ChainId chainId, DateTimeOffset createdAt) + public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, SessionChainId chainId, DateTimeOffset createdAt) { UserId = userId; SessionId = sessionId; diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs index 0c0ce5fb..34720489 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs @@ -33,7 +33,7 @@ public sealed class SessionRefreshedContext : IAuthEventContext /// /// Gets the identifier of the session chain to which both sessions belong. /// - public ChainId ChainId { get; } + public SessionChainId ChainId { get; } /// /// Gets the timestamp at which the refresh occurred. @@ -47,7 +47,7 @@ public SessionRefreshedContext( TUserId userId, AuthSessionId oldSessionId, AuthSessionId newSessionId, - ChainId chainId, + SessionChainId chainId, DateTimeOffset refreshedAt) { UserId = userId; diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs index ee7e98a2..fc5167ab 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs @@ -31,7 +31,7 @@ public sealed class SessionRevokedContext : IAuthEventContext /// /// Gets the identifier of the session chain containing the revoked session. /// - public ChainId ChainId { get; } + public SessionChainId ChainId { get; } /// /// Gets the timestamp at which the session revocation occurred. @@ -44,7 +44,7 @@ public sealed class SessionRevokedContext : IAuthEventContext public SessionRevokedContext( TUserId userId, AuthSessionId sessionId, - ChainId chainId, + SessionChainId chainId, DateTimeOffset revokedAt) { UserId = userId; diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs index 8d3cb463..3d604bcb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; @@ -86,7 +87,6 @@ private static IServiceCollection AddUltimateAuthInternal(this IServiceCollectio services.AddSingleton(); services.TryAddSingleton(); - return services; } diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs index 3f9d93fe..cf96f955 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs @@ -1,102 +1,102 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; +//using CodeBeam.UltimateAuth.Core.Abstractions; +//using Microsoft.Extensions.DependencyInjection; +//using Microsoft.Extensions.DependencyInjection.Extensions; -namespace CodeBeam.UltimateAuth.Core.Extensions -{ - /// - /// Provides extension methods for registering a concrete - /// implementation into the application's dependency injection container. - /// - /// UltimateAuth requires exactly one session store implementation that determines - /// how sessions, chains, and roots are persisted (e.g., EF Core, Dapper, Redis, MongoDB). - /// This extension performs automatic generic type resolution and registers the correct - /// ISessionStore<TUserId> for the application's user ID type. - /// - /// The method enforces that the provided store implements ISessionStore'TUserId';. - /// If the type cannot be determined, an exception is thrown to prevent misconfiguration. - /// - public static class UltimateAuthSessionStoreExtensions - { - /// - /// Registers a custom session store implementation for UltimateAuth. - /// The supplied must implement ISessionStore'TUserId'; - /// exactly once with a single TUserId generic argument. - /// - /// After registration, the internal session store factory resolves the correct - /// ISessionStore instance at runtime for the active tenant and TUserId type. - /// - /// The concrete session store implementation. - public static IServiceCollection AddUltimateAuthSessionStore(this IServiceCollection services) - where TStore : class - { - var storeInterface = typeof(TStore) - .GetInterfaces() - .FirstOrDefault(i => - i.IsGenericType && - i.GetGenericTypeDefinition() == typeof(ISessionStoreKernel<>)); +//namespace CodeBeam.UltimateAuth.Core.Extensions +//{ +// /// +// /// Provides extension methods for registering a concrete +// /// implementation into the application's dependency injection container. +// /// +// /// UltimateAuth requires exactly one session store implementation that determines +// /// how sessions, chains, and roots are persisted (e.g., EF Core, Dapper, Redis, MongoDB). +// /// This extension performs automatic generic type resolution and registers the correct +// /// ISessionStore<TUserId> for the application's user ID type. +// /// +// /// The method enforces that the provided store implements ISessionStore'TUserId';. +// /// If the type cannot be determined, an exception is thrown to prevent misconfiguration. +// /// +// public static class UltimateAuthSessionStoreExtensions +// { +// /// +// /// Registers a custom session store implementation for UltimateAuth. +// /// The supplied must implement ISessionStore'TUserId'; +// /// exactly once with a single TUserId generic argument. +// /// +// /// After registration, the internal session store factory resolves the correct +// /// ISessionStore instance at runtime for the active tenant and TUserId type. +// /// +// /// The concrete session store implementation. +// public static IServiceCollection AddUltimateAuthSessionStore(this IServiceCollection services) +// where TStore : class +// { +// var storeInterface = typeof(TStore) +// .GetInterfaces() +// .FirstOrDefault(i => +// i.IsGenericType && +// i.GetGenericTypeDefinition() == typeof(ISessionStoreKernel<>)); - if (storeInterface is null) - { - throw new InvalidOperationException( - $"{typeof(TStore).Name} must implement ISessionStoreKernel."); - } +// if (storeInterface is null) +// { +// throw new InvalidOperationException( +// $"{typeof(TStore).Name} must implement ISessionStoreKernel."); +// } - var userIdType = storeInterface.GetGenericArguments()[0]; - var typedInterface = typeof(ISessionStoreKernel<>).MakeGenericType(userIdType); +// var userIdType = storeInterface.GetGenericArguments()[0]; +// var typedInterface = typeof(ISessionStoreKernel<>).MakeGenericType(userIdType); - services.TryAddScoped(typedInterface, typeof(TStore)); +// services.TryAddScoped(typedInterface, typeof(TStore)); - services.AddSingleton(sp => - new GenericSessionStoreFactory(sp, userIdType)); +// services.AddSingleton(sp => +// new GenericSessionStoreFactory(sp, userIdType)); - return services; - } - } +// return services; +// } +// } - /// - /// Default session store factory used by UltimateAuth to dynamically create - /// the correct ISessionStore<TUserId> implementation at runtime. - /// - /// This factory ensures type safety by validating the requested TUserId against - /// the registered session store’s user ID type. Attempting to resolve a mismatched - /// TUserId results in a descriptive exception to prevent silent misconfiguration. - /// - /// Tenant ID is passed through so that multi-tenant implementations can perform - /// tenant-aware routing, filtering, or partition-based selection. - /// - internal sealed class GenericSessionStoreFactory : ISessionStoreFactory - { - private readonly IServiceProvider _sp; - private readonly Type _userIdType; +// /// +// /// Default session store factory used by UltimateAuth to dynamically create +// /// the correct ISessionStore<TUserId> implementation at runtime. +// /// +// /// This factory ensures type safety by validating the requested TUserId against +// /// the registered session store’s user ID type. Attempting to resolve a mismatched +// /// TUserId results in a descriptive exception to prevent silent misconfiguration. +// /// +// /// Tenant ID is passed through so that multi-tenant implementations can perform +// /// tenant-aware routing, filtering, or partition-based selection. +// /// +// internal sealed class GenericSessionStoreFactory : ISessionStoreFactory +// { +// private readonly IServiceProvider _sp; +// private readonly Type _userIdType; - /// - /// Initializes a new instance of the class. - /// - public GenericSessionStoreFactory(IServiceProvider sp, Type userIdType) - { - _sp = sp; - _userIdType = userIdType; - } +// /// +// /// Initializes a new instance of the class. +// /// +// public GenericSessionStoreFactory(IServiceProvider sp, Type userIdType) +// { +// _sp = sp; +// _userIdType = userIdType; +// } - /// - /// Creates and returns the registered ISessionStore<TUserId> implementation - /// for the specified tenant and user ID type. - /// Throws if the requested TUserId does not match the registered store's type. - /// - public ISessionStoreKernel Create(string? tenantId) - { - if (typeof(TUserId) != _userIdType) - { - throw new InvalidOperationException( - $"SessionStore registered for TUserId='{_userIdType.Name}', " + - $"but requested with TUserId='{typeof(TUserId).Name}'."); - } +// /// +// /// Creates and returns the registered ISessionStore<TUserId> implementation +// /// for the specified tenant and user ID type. +// /// Throws if the requested TUserId does not match the registered store's type. +// /// +// public ISessionStoreKernel Create(string? tenantId) +// { +// if (typeof(TUserId) != _userIdType) +// { +// throw new InvalidOperationException( +// $"SessionStore registered for TUserId='{_userIdType.Name}', " + +// $"but requested with TUserId='{typeof(TUserId).Name}'."); +// } - var typed = typeof(ISessionStoreKernel<>).MakeGenericType(_userIdType); - var store = _sp.GetRequiredService(typed); +// var typed = typeof(ISessionStoreKernel<>).MakeGenericType(_userIdType); +// var store = _sp.GetRequiredService(typed); - return (ISessionStoreKernel)store; - } - } -} +// return (ISessionStoreKernel)store; +// } +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs new file mode 100644 index 00000000..d8472f83 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class DeviceMismatchPolicy : IAuthorityPolicy + { + public bool AppliesTo(AuthContext context) + => context.Device is not null; + + public AuthorizationResult Decide(AuthContext context) + { + var device = context.Device; + + //if (device.IsKnownDevice) + // return AuthorizationResult.Allow(); + + return context.Operation switch + { + AuthOperation.Access => + AuthorizationResult.Deny("Access from unknown device."), + + AuthOperation.Refresh => + AuthorizationResult.Challenge("Device verification required."), + + AuthOperation.Login => AuthorizationResult.Allow(), // login establishes device + + _ => AuthorizationResult.Allow() + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs new file mode 100644 index 00000000..a2fc709b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class DevicePresenceInvariant : IAuthorityInvariant + { + public AuthorizationResult Decide(AuthContext context) + { + if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) + { + if (context.Device is null) + return AuthorizationResult.Deny("Device information is required."); + } + + return AuthorizationResult.Allow(); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs deleted file mode 100644 index 8bc3abc5..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Infrastructure -{ - public sealed class DeviceTrustPolicy : IAuthorityPolicy - { - public bool AppliesTo(AuthContext context) => context.Device is not null; - - public AuthorizationResult Decide(AuthContext context) - { - var device = context.Device; - - if (device.IsTrusted) - return AuthorizationResult.Allow(); - - return context.Operation switch - { - AuthOperation.Login => - AuthorizationResult.Challenge("Login from untrusted device requires additional verification."), - - AuthOperation.Refresh => - AuthorizationResult.Challenge("Token refresh from untrusted device requires additional verification."), - - AuthOperation.Access => - AuthorizationResult.Deny("Access from untrusted device is not allowed."), - - _ => AuthorizationResult.Allow() - }; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs index 75d026c3..6f2b08f4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs @@ -3,49 +3,52 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; -public sealed class DefaultRefreshTokenValidator : IRefreshTokenValidator +public sealed class DefaultRefreshTokenValidator : IRefreshTokenValidator { - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenStore _store; private readonly ITokenHasher _hasher; - public DefaultRefreshTokenValidator( - IRefreshTokenStore store, - ITokenHasher hasher) + public DefaultRefreshTokenValidator(IRefreshTokenStore store, ITokenHasher hasher) { _store = store; _hasher = hasher; } - public async Task> ValidateAsync( - string? tenantId, - string refreshToken, - DateTimeOffset now, - CancellationToken ct = default) + public async Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default) { - var hash = _hasher.Hash(refreshToken); - - var stored = await _store.FindByHashAsync(tenantId, hash, ct); + var hash = _hasher.Hash(context.RefreshToken); + var stored = await _store.FindByHashAsync(context.TenantId, hash, ct); if (stored is null) - return RefreshTokenValidationResult.Invalid(); + return RefreshTokenValidationResult.Invalid(); if (stored.IsRevoked) - return RefreshTokenValidationResult.ReuseDetected( + return RefreshTokenValidationResult.ReuseDetected( tenantId: stored.TenantId, sessionId: stored.SessionId, chainId: stored.ChainId, - userId: stored.UserId); + userKey: stored.UserKey); - if (stored.IsExpired(now)) + if (stored.IsExpired(context.Now)) { - await _store.RevokeAsync(tenantId, hash, now, ct); - return RefreshTokenValidationResult.Invalid(); + await _store.RevokeAsync(context.TenantId, hash, context.Now, null, ct); + return RefreshTokenValidationResult.Invalid(); } - return RefreshTokenValidationResult.Valid( + 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( tenantId: stored.TenantId, - stored.UserId, + stored.UserKey, stored.SessionId, + hash, stored.ChainId); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs index 01085ffe..26d0d1eb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using System.Globalization; using System.Text; @@ -65,6 +66,7 @@ public TUserId FromString(string value) Type t when t == typeof(long) => (TUserId)(object)long.Parse(value, CultureInfo.InvariantCulture), 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(UserKey) => (TUserId)(object)UserKey.FromString(value), _ => JsonSerializer.Deserialize(value) ?? throw new UAuthInternalException("Cannot deserialize TUserId") diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs index 7a14b542..8872df75 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs @@ -3,8 +3,8 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure { - public sealed class UserIdFactory : IUserIdFactory + public sealed class UserIdFactory : IUserIdFactory { - public UserId Create() => new UserId(Guid.NewGuid().ToString("N")); + public UserKey Create() => UserKey.New(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs index 3f74c77b..f8c75a2a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs @@ -7,6 +7,7 @@ public enum UAuthClientProfile BlazorServer, Maui, WebServer, - Api + Api, + UAuthHub = 1000 } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs index 593fed2b..b66d85cf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs @@ -16,9 +16,12 @@ public sealed class UAuthPkceOptions /// public int AuthorizationCodeLifetimeSeconds { get; set; } = 120; + public int MaxVerificationAttempts { get; set; } = 5; + internal UAuthPkceOptions Clone() => new() { - AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds + AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds, + MaxVerificationAttempts = MaxVerificationAttempts, }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs new file mode 100644 index 00000000..495e3cc4 --- /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 + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index c195989b..950b8fa3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -1,10 +1,13 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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, CredentialKind kind, string value); + void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId); + void Write(HttpContext context, CredentialKind kind, AccessToken accessToken); + void Write(HttpContext context, CredentialKind kind, RefreshToken refreshToken); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs index adb05d43..06b0998a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Http; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Server.Abstractions { diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs index 962be542..75edff58 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs @@ -10,10 +10,10 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions /// Extends the core ISessionIssuer contract with HttpContext-bound /// operations required for cookie-based session binding. /// - public interface IHttpSessionIssuer : ISessionIssuer + public interface IHttpSessionIssuer : ISessionIssuer { - Task> IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + Task IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default); - Task> RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken cancellationToken = default); + Task RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs index ff3b2813..885b37e6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -10,6 +10,6 @@ namespace CodeBeam.UltimateAuth.Server.Abstactions public interface ITokenIssuer { Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(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/Abstractions/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs index 417e8055..6c030046 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Abstractions { diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs index 856ec260..6313300f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs @@ -1,14 +1,47 @@ -namespace CodeBeam.UltimateAuth.Server.Auth +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth { internal sealed class DefaultAuthFlowContextAccessor : IAuthFlowContextAccessor { - private static readonly AsyncLocal _current = new(); + private static readonly object Key = new(); + + private readonly IHttpContextAccessor _http; + + public DefaultAuthFlowContextAccessor(IHttpContextAccessor http) + { + _http = http; + } + + public AuthFlowContext Current + { + get + { + var ctx = _http.HttpContext + ?? throw new InvalidOperationException("No HttpContext."); - public AuthFlowContext Current => _current.Value ?? throw new InvalidOperationException("AuthFlowContext is not available for this request."); + 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) { - _current.Value = context; + var ctx = _http.HttpContext + ?? throw new InvalidOperationException("No HttpContext."); + + ctx.Items[Key] = context; } + + //private static readonly AsyncLocal _current = new(); + + //public AuthFlowContext Current => _current.Value ?? throw new InvalidOperationException("AuthFlowContext is not available for this request."); + + //internal void Set(AuthFlowContext context) + //{ + // _current.Value = context; + //} } } 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..bb1fff7a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public sealed record AuthExecutionContext + { + public required UAuthClientProfile? EffectiveClientProfile { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs index ad1f31c9..253b0c65 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -12,18 +12,16 @@ public sealed class AuthFlowContext public AuthFlowType FlowType { get; } public UAuthClientProfile ClientProfile { get; } public UAuthMode EffectiveMode { get; } - + public DeviceContext Device { get; } public string? TenantId { get; } + public SessionSecurityContext? Session { get; } public bool IsAuthenticated { get; } - public UserId? UserId { get; } - public AuthSessionId? SessionId { get; } - + public UserKey? UserKey { get; } public UAuthServerOptions OriginalOptions { get; } public EffectiveUAuthServerOptions EffectiveOptions { get; } - public EffectiveAuthResponse Response { get; } public PrimaryTokenKind PrimaryTokenKind { get; } @@ -36,10 +34,11 @@ internal AuthFlowContext( AuthFlowType flowType, UAuthClientProfile clientProfile, UAuthMode effectiveMode, + DeviceContext device, string? tenantId, bool isAuthenticated, - UserId? userId, - AuthSessionId? sessionId, + UserKey? userKey, + SessionSecurityContext? session, UAuthServerOptions originalOptions, EffectiveUAuthServerOptions effectiveOptions, EffectiveAuthResponse response, @@ -48,11 +47,12 @@ internal AuthFlowContext( FlowType = flowType; ClientProfile = clientProfile; EffectiveMode = effectiveMode; + Device = device; TenantId = tenantId; + Session = session; IsAuthenticated = isAuthenticated; - UserId = userId; - SessionId = sessionId; + UserKey = userKey; OriginalOptions = originalOptions; EffectiveOptions = effectiveOptions; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index d064978e..b2c02f8f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -1,5 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; @@ -7,7 +10,7 @@ namespace CodeBeam.UltimateAuth.Server.Auth { public interface IAuthFlowContextFactory { - AuthFlowContext Create(HttpContext httpContext, AuthFlowType flowType); + ValueTask CreateAsync(HttpContext httpContext, AuthFlowType flowType, CancellationToken ct = default); } internal sealed class DefaultAuthFlowContextFactory : IAuthFlowContextFactory @@ -16,23 +19,32 @@ internal sealed class DefaultAuthFlowContextFactory : IAuthFlowContextFactory private readonly IPrimaryTokenResolver _primaryTokenResolver; private readonly IEffectiveServerOptionsProvider _serverOptionsProvider; private readonly IAuthResponseResolver _authResponseResolver; + private readonly IDeviceResolver _deviceResolver; + private readonly IDeviceContextFactory _deviceContextFactory; + private readonly ISessionQueryService _sessionQueryService; public DefaultAuthFlowContextFactory( IClientProfileReader clientProfileReader, IPrimaryTokenResolver primaryTokenResolver, IEffectiveServerOptionsProvider serverOptionsProvider, - IAuthResponseResolver authResponseResolver) + IAuthResponseResolver authResponseResolver, + IDeviceResolver deviceResolver, + IDeviceContextFactory deviceContextFactory, + ISessionQueryService sessionQueryService) { _clientProfileReader = clientProfileReader; _primaryTokenResolver = primaryTokenResolver; _serverOptionsProvider = serverOptionsProvider; _authResponseResolver = authResponseResolver; + _deviceResolver = deviceResolver; + _deviceContextFactory = deviceContextFactory; + _sessionQueryService = sessionQueryService; } - public AuthFlowContext Create(HttpContext ctx, AuthFlowType flowType) + public async ValueTask CreateAsync(HttpContext ctx, AuthFlowType flowType, CancellationToken ct = default) { var tenant = ctx.GetTenantContext(); - var session = ctx.GetSessionContext(); + var sessionCtx = ctx.GetSessionContext(); var user = ctx.GetUserContext(); var clientProfile = _clientProfileReader.Read(ctx); @@ -44,6 +56,26 @@ public AuthFlowContext Create(HttpContext ctx, AuthFlowType flowType) var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions); + var deviceInfo = _deviceResolver.Resolve(ctx); + var deviceContext = _deviceContextFactory.Create(deviceInfo); + + SessionSecurityContext? sessionSecurityContext = null; + + if (!sessionCtx.IsAnonymous) + { + var validation = await _sessionQueryService.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = sessionCtx.TenantId, + SessionId = sessionCtx.SessionId!.Value, + Device = deviceContext, + Now = DateTimeOffset.UtcNow + }, + ct); + + sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation); + } + // TODO: Implement invariant checker //_invariantChecker.Validate(flowType, effectiveMode, response, effectiveOptions); @@ -51,10 +83,11 @@ public AuthFlowContext Create(HttpContext ctx, AuthFlowType flowType) flowType, clientProfile, effectiveMode, + deviceContext, tenant?.TenantId, user?.IsAuthenticated ?? false, user?.UserId, - session?.SessionId, + sessionSecurityContext, originalOptions, effectiveOptions, response, diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs index a3586c2a..f1e4bcef 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs @@ -17,9 +17,8 @@ public AuthFlowEndpointFilter(IAuthFlow authFlow) if (metadata != null) { - _authFlow.Begin(metadata.FlowType); + await _authFlow.BeginAsync(metadata.FlowType); } - return await next(context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs index 891abf0f..ca13cf15 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs @@ -19,11 +19,11 @@ public DefaultAuthFlow( _accessor = (DefaultAuthFlowContextAccessor)accessor; } - public AuthFlowContext Begin(AuthFlowType flowType) + public async ValueTask BeginAsync(AuthFlowType flowType, CancellationToken ct = default) { var ctx = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext."); - var flowContext = _factory.Create(ctx, flowType); + var flowContext = await _factory.CreateAsync(ctx, flowType); _accessor.Set(flowContext); return flowContext; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs index e4fbc7cb..41c6e1e9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs @@ -4,6 +4,6 @@ namespace CodeBeam.UltimateAuth.Server.Auth { public interface IAuthFlow { - AuthFlowContext Begin(AuthFlowType flowType); + ValueTask BeginAsync(AuthFlowType flowType, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs index 11c039b7..bccddfa6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -13,11 +13,8 @@ namespace CodeBeam.UltimateAuth.Server.Authentication; internal sealed class UAuthAuthenticationHandler : AuthenticationHandler { private readonly ITransportCredentialResolver _transportCredentialResolver; - private readonly IFlowCredentialResolver _credentialResolver; - private readonly ISessionQueryService _sessionQuery; - private readonly IAuthFlowContextFactory _flowFactory; - private readonly IAuthResponseResolver _responseResolver; - + private readonly ISessionQueryService _sessionQuery; + private readonly IDeviceContextFactory _deviceContextFactory; private readonly IClock _clock; public UAuthAuthenticationHandler( @@ -26,18 +23,14 @@ public UAuthAuthenticationHandler( ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder, ISystemClock clock, - IFlowCredentialResolver credentialResolver, - ISessionQueryService sessionQuery, - IAuthFlowContextFactory flowFactory, - IAuthResponseResolver responseResolver, + ISessionQueryService sessionQuery, + IDeviceContextFactory deviceContextFactory, IClock uauthClock) : base(options, logger, encoder, clock) { _transportCredentialResolver = transportCredentialResolver; - _credentialResolver = credentialResolver; _sessionQuery = sessionQuery; - _flowFactory = flowFactory; - _responseResolver = responseResolver; + _deviceContextFactory = deviceContextFactory; _clock = uauthClock; } protected override async Task HandleAuthenticateAsync() @@ -55,11 +48,11 @@ protected override async Task HandleAuthenticateAsync() { TenantId = credential.TenantId, SessionId = sessionId, - Device = credential.Device, + Device = _deviceContextFactory.Create(credential.Device), Now = _clock.UtcNow }); - if (!result.IsValid) + if (!result.IsValid || result.UserKey is null) return AuthenticateResult.NoResult(); var principal = CreatePrincipal(result); @@ -68,12 +61,12 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Success(ticket); } - private static ClaimsPrincipal CreatePrincipal(SessionValidationResult result) + private static ClaimsPrincipal CreatePrincipal(SessionValidationResult result) { var claims = new List { - new Claim(ClaimTypes.NameIdentifier, result.Session.UserId.Value), - new Claim("uauth:session_id", result.Session.SessionId.Value) + new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value), + new Claim("uauth:session_id", result.SessionId.ToString()) }; if (!string.IsNullOrEmpty(result.TenantId)) @@ -82,7 +75,7 @@ private static ClaimsPrincipal CreatePrincipal(SessionValidationResult r } // Session claims (snapshot) - foreach (var (key, value) in result.Session.Claims.AsDictionary()) + foreach (var (key, value) in result.Claims.AsDictionary()) { claims.Add(new Claim(key, value)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs index 8424440d..780d68f1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs +++ b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs @@ -15,7 +15,7 @@ public static IServiceCollection Build(this UltimateAuthServerBuilder builder) 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<>)))) + 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/Cookies/DefaultUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs index bd5f6da8..088b17fd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; @@ -6,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Cookies; internal sealed class DefaultUAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder { - public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, TimeSpan? logicalLifetime) + public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind) { if (response.Cookie is null) throw new InvalidOperationException("Cookie policy requested but Cookie options are null."); @@ -18,11 +20,11 @@ public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext c HttpOnly = src.HttpOnly, Secure = src.SecurePolicy == CookieSecurePolicy.Always, Path = src.Path, - Domain = src.Domain + Domain = src.Domain, + SameSite = ResolveSameSite(src, context) }; - options.SameSite = ResolveSameSite(src, context); - ApplyLifetime(options, src, logicalLifetime); + ApplyLifetime(options, src, context, kind); return options; } @@ -41,30 +43,51 @@ private static SameSiteMode ResolveSameSite(UAuthCookieOptions cookie, AuthFlowC }; } - private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, TimeSpan? logicalLifetime) + private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, AuthFlowContext context, CredentialKind kind) { var buffer = src.Lifetime.IdleBuffer ?? TimeSpan.Zero; - TimeSpan? baseLifetime = null; + var baseLifetime = ResolveBaseLifetime(context, kind, src); - // 1️⃣ Hard MaxAge override (base) - if (src.MaxAge is not null) - { - baseLifetime = src.MaxAge; - } - // 2️⃣ Absolute lifetime override (base) - else if (src.Lifetime.AbsoluteLifetimeOverride is not null) + if (baseLifetime is not null) { - baseLifetime = src.Lifetime.AbsoluteLifetimeOverride; + target.MaxAge = baseLifetime.Value + buffer; } - // 3️⃣ Logical lifetime (effective) - else if (logicalLifetime is not null) + } + + private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, CredentialKind 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 { - baseLifetime = logicalLifetime; - } + CredentialKind.Session => ResolveSessionLifetime(context), + CredentialKind.RefreshToken => context.EffectiveOptions.Options.Tokens.RefreshTokenLifetime, + CredentialKind.AccessToken => context.EffectiveOptions.Options.Tokens.AccessTokenLifetime, + _ => null + }; + } - if (baseLifetime is not null) + private static TimeSpan? ResolveSessionLifetime(AuthFlowContext context) + { + var sessionIdle = context.EffectiveOptions.Options.Session.IdleTimeout; + var refresh = context.EffectiveOptions.Options.Tokens.RefreshTokenLifetime; + + return context.EffectiveMode switch { - target.MaxAge = baseLifetime.Value + buffer; - } + 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/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs index f80a2bca..f90df8ea 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs @@ -2,10 +2,11 @@ using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Cookies; public interface IUAuthCookiePolicyBuilder { - CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, TimeSpan? logicalLifetime); + CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs index d9324a46..f26dc4a7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs @@ -4,8 +4,18 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints { public interface IPkceEndpointHandler { - Task CreateAsync(HttpContext ctx); - Task VerifyAsync(HttpContext ctx); - Task ConsumeAsync(HttpContext ctx); + /// + /// 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); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index d44f90ad..9131458d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -1,43 +1,34 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; 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.Contracts; -using CodeBeam.UltimateAuth.Server.Cookies; -using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Endpoints; public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler { - private readonly IAuthFlowContextAccessor _authContext; - private readonly IUAuthFlowService _flow; - private readonly IDeviceResolver _deviceResolver; + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IUAuthFlowService _flowService; private readonly IClock _clock; private readonly ICredentialResponseWriter _credentialResponseWriter; private readonly AuthRedirectResolver _redirectResolver; public DefaultLoginEndpointHandler( - IAuthFlowContextAccessor authContext, - IUAuthFlowService flow, - IDeviceResolver deviceResolver, + IAuthFlowContextAccessor authFlow, + IUAuthFlowService flowService, IClock clock, ICredentialResponseWriter credentialResponseWriter, AuthRedirectResolver redirectResolver) { - _authContext = authContext; - _flow = flow; - _deviceResolver = deviceResolver; + _authFlow = authFlow; + _flowService = flowService; _clock = clock; _credentialResponseWriter = credentialResponseWriter; _redirectResolver = redirectResolver; @@ -45,11 +36,11 @@ public DefaultLoginEndpointHandler( public async Task LoginAsync(HttpContext ctx) { - var auth = _authContext.Current; + var authFlow = _authFlow.Current; var shouldIssueTokens = - auth.Response.AccessTokenDelivery.Mode != TokenResponseMode.None || - auth.Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; + authFlow.Response.AccessTokenDelivery.Mode != TokenResponseMode.None || + authFlow.Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; if (!ctx.Request.HasFormContentType) return Results.BadRequest("Invalid content type."); @@ -60,7 +51,7 @@ public async Task LoginAsync(HttpContext ctx) var secret = form["Secret"].ToString(); if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret)) - return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, auth.OriginalOptions); + return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, authFlow.OriginalOptions); var tenantCtx = ctx.GetTenantContext(); @@ -70,41 +61,36 @@ public async Task LoginAsync(HttpContext ctx) Secret = secret, TenantId = tenantCtx.TenantId, At = _clock.UtcNow, - DeviceInfo = _deviceResolver.Resolve(ctx), + Device = authFlow.Device, RequestTokens = shouldIssueTokens }; - var result = await _flow.LoginAsync(auth, flowRequest, ctx.RequestAborted); + var result = await _flowService.LoginAsync(authFlow, flowRequest, ctx.RequestAborted); if (!result.IsSuccess) - return RedirectFailure(ctx, result.FailureReason ?? AuthFailureReason.Unknown, auth.OriginalOptions); + return RedirectFailure(ctx, result.FailureReason ?? AuthFailureReason.Unknown, authFlow.OriginalOptions); - if (result.SessionId is not null) + if (result.SessionId is AuthSessionId sessionId) { - _credentialResponseWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + _credentialResponseWriter.Write(ctx, CredentialKind.Session, sessionId); } if (result.AccessToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken.Token); + _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); } if (result.RefreshToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken.Token); + _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); } - if (auth.Response.Login.RedirectEnabled) + if (authFlow.Response.Login.RedirectEnabled) { - var redirectUrl = - _redirectResolver.ResolveRedirect( - ctx, - auth.Response.Login.SuccessPath); - + var redirectUrl = _redirectResolver.ResolveRedirect(ctx, authFlow.Response.Login.SuccessPath); return Results.Redirect(redirectUrl); } - // PKCE / API login return Results.Ok(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs index d8127e92..9a99f0ef 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs @@ -32,13 +32,13 @@ public async Task LogoutAsync(HttpContext ctx) { var auth = _authContext.Current; - if (auth.SessionId != null) + if (auth.Session is SessionSecurityContext session) { var request = new LogoutRequest { TenantId = auth.TenantId, - SessionId = auth.SessionId.Value, - At = _clock.UtcNow + SessionId = session.SessionId, + At = _clock.UtcNow, }; await _flow.LogoutAsync(request, ctx.RequestAborted); @@ -65,6 +65,9 @@ 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/DefaultPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs index 873a6fb1..a4db8cfa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs @@ -1,16 +1,249 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Abstractions; +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.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class DefaultPkceEndpointHandler : IPkceEndpointHandler { - public class DefaultPkceEndpointHandler : IPkceEndpointHandler + private readonly IAuthFlowContextAccessor _authContext; + private readonly IUAuthFlowService _flow; + private readonly IAuthStore _authStore; + private readonly IPkceAuthorizationValidator _validator; + private readonly IClock _clock; + private readonly UAuthPkceOptions _pkceOptions; + private readonly ICredentialResponseWriter _credentialResponseWriter; + private readonly AuthRedirectResolver _redirectResolver; + + public DefaultPkceEndpointHandler( + IAuthFlowContextAccessor authContext, + IUAuthFlowService flow, + IAuthStore authStore, + IPkceAuthorizationValidator validator, + IClock clock, + IOptions pkceOptions, + ICredentialResponseWriter credentialResponseWriter, + AuthRedirectResolver redirectResolver) + { + _authContext = authContext; + _flow = flow; + _authStore = authStore; + _validator = validator; + _clock = clock; + _pkceOptions = pkceOptions.Value; + _credentialResponseWriter = credentialResponseWriter; + _redirectResolver = redirectResolver; + } + + public async Task AuthorizeAsync(HttpContext ctx) + { + var authContext = _authContext.Current; + + if (authContext.FlowType != AuthFlowType.Login) + return Results.BadRequest("PKCE is only supported for login flow."); + + var request = await ReadPkceAuthorizeRequestAsync(ctx); + if (request is null) + return Results.BadRequest("Invalid content type."); + + if (string.IsNullOrWhiteSpace(request.CodeChallenge)) + return Results.BadRequest("code_challenge is required."); + + if (!string.Equals(request.ChallengeMethod, "S256", StringComparison.Ordinal)) + return Results.BadRequest("Only S256 challenge method is supported."); + + var authorizationCode = AuthArtifactKey.New(); + + var snapshot = new PkceContextSnapshot( + clientProfile: authContext.ClientProfile, + tenantId: authContext.TenantId, + redirectUri: request.RedirectUri, + deviceId: string.Empty // TODO: Fix here with device binding + ); + + var expiresAt = _clock.UtcNow.AddSeconds(_pkceOptions.AuthorizationCodeLifetimeSeconds); + + var artifact = new PkceAuthorizationArtifact( + authorizationCode: authorizationCode, + codeChallenge: request.CodeChallenge, + challengeMethod: PkceChallengeMethod.S256, + expiresAt: expiresAt, + maxAttempts: _pkceOptions.MaxVerificationAttempts, + context: snapshot + ); + + await _authStore.StoreAsync(authorizationCode, artifact, ctx.RequestAborted); + + return Results.Ok(new PkceAuthorizeResponse + { + AuthorizationCode = authorizationCode.Value, + ExpiresIn = _pkceOptions.AuthorizationCodeLifetimeSeconds + }); + } + + public async Task CompleteAsync(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 completion 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.ConsumeAsync(artifactKey, ctx.RequestAborted) as PkceAuthorizationArtifact; + + if (artifact is null) + return Results.Unauthorized(); // replay / expired / unknown code + + var validation = _validator.Validate(artifact, request.CodeVerifier, + new PkceContextSnapshot( + clientProfile: authContext.ClientProfile, + tenantId: authContext.TenantId, + redirectUri: null, + deviceId: string.Empty), + _clock.UtcNow); + + if (!validation.Success) + { + artifact.RegisterAttempt(); + return RedirectToLoginWithError(ctx, authContext, "invalid"); + } + + var loginRequest = new LoginRequest + { + Identifier = request.Identifier, + Secret = request.Secret, + TenantId = authContext.TenantId, + At = _clock.UtcNow, + Device = authContext.Device, + RequestTokens = authContext.AllowsTokenIssuance + }; + + var execution = new AuthExecutionContext + { + EffectiveClientProfile = artifact.Context.ClientProfile, + }; + + var result = await _flow.LoginAsync(authContext, execution, loginRequest, ctx.RequestAborted); + + if (!result.IsSuccess) + return RedirectToLoginWithError(ctx, authContext, "invalid"); + + if (result.SessionId is not null) + { + _credentialResponseWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + } + + if (result.AccessToken is not null) + { + _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + } + + if (result.RefreshToken is not null) + { + _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + } + + if (authContext.Response.Login.RedirectEnabled) + { + var redirectUrl = request.ReturnUrl ?? _redirectResolver.ResolveRedirect(ctx, authContext.Response.Login.SuccessPath); + return Results.Redirect(redirectUrl); + } + + return Results.Ok(); + } + + private static async Task ReadPkceAuthorizeRequestAsync(HttpContext ctx) { - public Task CreateAsync(HttpContext ctx) - => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); + if (ctx.Request.HasJsonContentType()) + { + return await ctx.Request.ReadFromJsonAsync(cancellationToken: ctx.RequestAborted); + } + + if (ctx.Request.HasFormContentType) + { + var form = await ctx.Request.ReadFormAsync(ctx.RequestAborted); - public Task VerifyAsync(HttpContext ctx) - => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); + var codeChallenge = form["code_challenge"].ToString(); + var challengeMethod = form["challenge_method"].ToString(); + var redirectUri = form["redirect_uri"].ToString(); - public Task ConsumeAsync(HttpContext ctx) - => Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); + return new PkceAuthorizeRequest + { + CodeChallenge = codeChallenge, + ChallengeMethod = challengeMethod, + RedirectUri = string.IsNullOrWhiteSpace(redirectUri) ? null : redirectUri + }; + } + + 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.Request.ReadFormAsync(ctx.RequestAborted); + + var authorizationCode = form["authorization_code"].ToString(); + var codeVerifier = form["code_verifier"].ToString(); + var identifier = form["Identifier"].ToString(); + var secret = form["Secret"].ToString(); + var returnUrl = form["return_url"].ToString(); + + return new PkceCompleteRequest + { + AuthorizationCode = authorizationCode, + CodeVerifier = codeVerifier, + Identifier = identifier, + Secret = secret, + ReturnUrl = returnUrl + }; + } + + return null; + } + + private IResult RedirectToLoginWithError(HttpContext ctx, AuthFlowContext auth, string error) + { + var basePath = auth.OriginalOptions.Hub.LoginPath ?? "/login"; + + var hubKey = ctx.Request.Query["hub"].ToString(); + + if (!string.IsNullOrWhiteSpace(hubKey)) + { + var key = new AuthArtifactKey(hubKey); + var artifact = _authStore.GetAsync(key, ctx.RequestAborted).Result; + + if (artifact is HubFlowArtifact hub) + { + hub.MarkCompleted(); + _authStore.StoreAsync(key, hub, ctx.RequestAborted); + } + return Results.Redirect($"{basePath}?hub={Uri.EscapeDataString(hubKey)}&__uauth_error={Uri.EscapeDataString(error)}"); + } + + return Results.Redirect($"{basePath}?__uauth_error={Uri.EscapeDataString(error)}"); + } + } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs index 6ee312b7..83b703ad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs @@ -8,111 +8,74 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints { - public sealed class DefaultRefreshEndpointHandler : IRefreshEndpointHandler where TUserId : notnull + public sealed class DefaultRefreshEndpointHandler : IRefreshEndpointHandler { private readonly IAuthFlowContextAccessor _authContext; - private readonly ISessionRefreshService _sessionRefresh; - private readonly IRefreshTokenRotationService _tokenRotation; + private readonly IRefreshFlowService _refreshFlow; private readonly ICredentialResponseWriter _credentialWriter; private readonly IRefreshResponseWriter _refreshWriter; - private readonly ISessionQueryService _sessionQueries; private readonly IRefreshTokenResolver _refreshTokenResolver; + private readonly IRefreshResponsePolicy _refreshPolicy; public DefaultRefreshEndpointHandler( IAuthFlowContextAccessor authContext, - ISessionRefreshService sessionRefresh, - IRefreshTokenRotationService tokenRotation, + IRefreshFlowService refreshFlow, ICredentialResponseWriter credentialWriter, IRefreshResponseWriter refreshWriter, - ISessionQueryService sessionQueries, - IRefreshTokenResolver refreshTokenResolver) + IRefreshTokenResolver refreshTokenResolver, + IRefreshResponsePolicy refreshPolicy) { _authContext = authContext; - _sessionRefresh = sessionRefresh; - _tokenRotation = tokenRotation; + _refreshFlow = refreshFlow; _credentialWriter = credentialWriter; _refreshWriter = refreshWriter; - _sessionQueries = sessionQueries; _refreshTokenResolver = refreshTokenResolver; + _refreshPolicy = refreshPolicy; } public async Task RefreshAsync(HttpContext ctx) { - var auth = _authContext.Current; - var decision = RefreshDecisionResolver.Resolve(auth.EffectiveMode); + var flow = _authContext.Current; - return decision switch + if (flow.Session is not SessionSecurityContext session) { - RefreshDecision.SessionTouch => await HandleSessionTouchAsync(ctx, auth, auth.Response), - RefreshDecision.TokenRotation => await HandleTokenRotationAsync(ctx, auth, auth.Response), + //_logger.LogDebug("Refresh called without active session."); + return Results.Ok(RefreshOutcome.None); + } - _ => Results.StatusCode(StatusCodes.Status409Conflict) + var request = new RefreshFlowRequest + { + SessionId = session.SessionId, + RefreshToken = _refreshTokenResolver.Resolve(ctx), + Device = flow.Device, + Now = DateTimeOffset.UtcNow }; - } - - private async Task HandleSessionTouchAsync(HttpContext ctx, AuthFlowContext flow, EffectiveAuthResponse response) - { - if (flow.SessionId is null) - return Results.Unauthorized(); - var now = DateTimeOffset.UtcNow; - var validation = await _sessionQueries.ValidateSessionAsync( - new SessionValidationContext - { - TenantId = flow.TenantId, - SessionId = flow.SessionId.Value, - Now = now, - Device = DeviceInfoFactory.FromHttpContext(ctx) - }, - ctx.RequestAborted); + var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted); - if (!validation.IsValid) + if (!result.Succeeded) { WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); return Results.Unauthorized(); } - var result = await _sessionRefresh.RefreshAsync(validation, now, ctx.RequestAborted); + var primary = _refreshPolicy.SelectPrimary(flow, request, result); - if (!result.IsSuccess || result.PrimaryToken is null) + if (primary == CredentialKind.Session && result.SessionId is not null) { - WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); - return Results.Unauthorized(); + _credentialWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); } - - _credentialWriter.Write(ctx, CredentialKind.Session, result.PrimaryToken.Value); - WriteRefreshHeader(ctx, flow, result.DidTouch ? RefreshOutcome.Touched : RefreshOutcome.NoOp); - - return Results.NoContent(); - } - - private async Task HandleTokenRotationAsync(HttpContext ctx, AuthFlowContext flow, EffectiveAuthResponse response) - { - var refreshToken = _refreshTokenResolver.Resolve(ctx); - if (refreshToken is null) - return Results.Unauthorized(); - - var now = DateTimeOffset.UtcNow; - - var result = await _tokenRotation.RotateAsync( - flow, - new RefreshTokenRotationContext - { - RefreshToken = refreshToken, - Now = DateTimeOffset.UtcNow - }, - ctx.RequestAborted); - - if (!result.IsSuccess) + else if (primary == CredentialKind.AccessToken && result.AccessToken is not null) { - WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); - return Results.Unauthorized(); + _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); } - _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken.Token); - _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken.Token); - WriteRefreshHeader(ctx, flow, RefreshOutcome.Rotated); + if (_refreshPolicy.WriteRefreshToken(flow) && result.RefreshToken is not null) + { + _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + } + WriteRefreshHeader(ctx, flow, result.Outcome); return Results.NoContent(); } @@ -123,5 +86,6 @@ private void WriteRefreshHeader(HttpContext ctx, AuthFlowContext flow, RefreshOu _refreshWriter.Write(ctx, outcome); } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs index 33ab7252..5bd10ac6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs @@ -8,17 +8,17 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints { - internal sealed class DefaultValidateEndpointHandler : IValidateEndpointHandler + internal sealed class DefaultValidateEndpointHandler : IValidateEndpointHandler { private readonly IAuthFlowContextAccessor _authContext; private readonly IFlowCredentialResolver _credentialResolver; - private readonly ISessionQueryService _sessionValidator; + private readonly ISessionQueryService _sessionValidator; private readonly IClock _clock; public DefaultValidateEndpointHandler( IAuthFlowContextAccessor authContext, IFlowCredentialResolver credentialResolver, - ISessionQueryService sessionValidator, + ISessionQueryService sessionValidator, IClock clock) { _authContext = authContext; @@ -64,7 +64,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken TenantId = credential.TenantId, SessionId = sessionId, Now = _clock.UtcNow, - Device = credential.Device + Device = auth.Device }, ct); @@ -74,10 +74,10 @@ public async Task ValidateAsync(HttpContext context, CancellationToken State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant(), Snapshot = new AuthStateSnapshot { - UserId = result?.Session?.UserId?.ToString(), - TenantId = result?.TenantId, - Claims = result?.Session?.Claims ?? ClaimsSnapshot.Empty, - AuthenticatedAt = result?.Session?.CreatedAt, + UserId = result.UserKey, + TenantId = result.TenantId, + Claims = result.Claims, + AuthenticatedAt = _clock.UtcNow, } }); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs index 48c1d39b..7b769f14 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs @@ -5,15 +5,13 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints { internal sealed class LoginEndpointHandlerBridge : ILoginEndpointHandler { - private readonly DefaultLoginEndpointHandler _inner; + private readonly DefaultLoginEndpointHandler _inner; - public LoginEndpointHandlerBridge(DefaultLoginEndpointHandler inner) + public LoginEndpointHandlerBridge(DefaultLoginEndpointHandler inner) { _inner = inner; } - public Task LoginAsync(HttpContext ctx) - => _inner.LoginAsync(ctx); + public Task LoginAsync(HttpContext ctx) => _inner.LoginAsync(ctx); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs index 54095f22..f35f05cc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs @@ -5,9 +5,9 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints { internal sealed class LogoutEndpointHandlerBridge : ILogoutEndpointHandler { - private readonly DefaultLogoutEndpointHandler _inner; + private readonly DefaultLogoutEndpointHandler _inner; - public LogoutEndpointHandlerBridge(DefaultLogoutEndpointHandler inner) + public LogoutEndpointHandlerBridge(DefaultLogoutEndpointHandler inner) { _inner = inner; } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs new file mode 100644 index 00000000..8f8f803b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + internal sealed class PkceEndpointHandlerBridge : IPkceEndpointHandler + { + private readonly DefaultPkceEndpointHandler _inner; + + public PkceEndpointHandlerBridge(DefaultPkceEndpointHandler 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/RefreshEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs index 28a885a8..22e776fa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs @@ -1,19 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints { internal sealed class RefreshEndpointHandlerBridge : IRefreshEndpointHandler { - private readonly DefaultRefreshEndpointHandler _inner; + private readonly DefaultRefreshEndpointHandler _inner; - public RefreshEndpointHandlerBridge( - DefaultRefreshEndpointHandler inner) + public RefreshEndpointHandlerBridge(DefaultRefreshEndpointHandler inner) { _inner = inner; } - public Task RefreshAsync(HttpContext ctx) - => _inner.RefreshAsync(ctx); + public Task RefreshAsync(HttpContext ctx) => _inner.RefreshAsync(ctx); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 728a8a6d..0d3ccc92 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -21,10 +21,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { // Base: /auth string basePrefix = options.RoutePrefix.TrimStart('/'); - - bool useRouteTenant = - options.MultiTenant.Enabled && - options.MultiTenant.EnableRoute; + bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute; RouteGroupBuilder group = useRouteTenant ? rootGroup.MapGroup("/{tenant}/" + basePrefix) @@ -32,23 +29,6 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.AddEndpointFilter(); - if (options.EnablePkceEndpoints != false) - { - var pkce = group.MapGroup("/pkce"); - - pkce.MapPost("/create", - async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); - - pkce.MapPost("/verify", - async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.VerifyAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); - - pkce.MapPost("/consume", - async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.ConsumeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); - } - if (options.EnableLoginEndpoints != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) @@ -67,6 +47,17 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); } + if (options.EnablePkceEndpoints != 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)); + } + if (options.EnableTokenEndpoints != false) { var token = group.MapGroup(""); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs index a5df8035..f9a2be56 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs @@ -5,14 +5,13 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints { internal sealed class ValidateEndpointHandlerBridge : IValidateEndpointHandler { - private readonly DefaultValidateEndpointHandler _inner; + private readonly DefaultValidateEndpointHandler _inner; - public ValidateEndpointHandlerBridge(DefaultValidateEndpointHandler inner) + public ValidateEndpointHandlerBridge(DefaultValidateEndpointHandler inner) { _inner = inner; } - public Task ValidateAsync(HttpContext context, CancellationToken ct = default) - => _inner.ValidateAsync(context, ct); + public Task ValidateAsync(HttpContext context, CancellationToken ct = default) => _inner.ValidateAsync(context, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs new file mode 100644 index 00000000..fb276f16 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Options; +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 + { + TenantId = flow.TenantId, + Operation = flow.FlowType.ToAuthOperation(), + Mode = flow.EffectiveMode, + At = now, + Device = flow.Device, + Session = flow.Session + }; + } + + public static AuthFlowContext WithClientProfile(this AuthFlowContext flow, UAuthClientProfile profile) + { + return new AuthFlowContext( + flow.FlowType, + profile, + flow.EffectiveMode, + flow.Device, + flow.TenantId, + flow.IsAuthenticated, + flow.UserKey, + flow.Session, + flow.OriginalOptions, + flow.EffectiveOptions, + flow.Response, + flow.PrimaryTokenKind); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs new file mode 100644 index 00000000..ea1803c8 --- /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.ApiAccess => AuthOperation.Access, + 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.QuerySession => AuthOperation.System, + + _ => throw new InvalidOperationException($"Unsupported flow type: {flowType}") + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs index 84f1a4f4..e6fbcf67 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Abstractions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs index 2a2d25ae..ed598241 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs @@ -7,14 +7,14 @@ namespace CodeBeam.UltimateAuth.Server.Extensions { public static class HttpContextUserExtensions { - public static AuthUserSnapshot GetUserContext(this HttpContext ctx) + public static AuthUserSnapshot GetUserContext(this HttpContext ctx) { - if (ctx.Items.TryGetValue(UserMiddleware.UserContextKey, out var value) && value is AuthUserSnapshot user) + if (ctx.Items.TryGetValue(UserMiddleware.UserContextKey, out var value) && value is AuthUserSnapshot user) { return user; } - return AuthUserSnapshot.Anonymous(); + return AuthUserSnapshot.Anonymous(); } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index 22335841..daba2baf 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -10,11 +10,13 @@ using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Hub; using CodeBeam.UltimateAuth.Server.Infrastructure.Session; using CodeBeam.UltimateAuth.Server.Issuers; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; using CodeBeam.UltimateAuth.Server.Users; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -126,9 +128,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.AddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); - services.AddScoped(typeof(IUAuthSessionService<>), typeof(UAuthSessionService<>)); + services.AddScoped(typeof(IRefreshFlowService), typeof(DefaultRefreshFlowService)); + services.AddScoped(typeof(IUAuthSessionManager), typeof(UAuthSessionManager)); services.AddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); - services.AddScoped(typeof(IUAuthTokenService<>), typeof(UAuthTokenService<>)); services.AddSingleton(); @@ -146,18 +148,18 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol // ----------------------------- // SESSION / TOKEN ISSUERS // ----------------------------- - services.TryAddScoped(typeof(ISessionIssuer<>), typeof(UAuthSessionIssuer<>)); - services.TryAddScoped(); + services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); + services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); - services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); services.TryAddScoped(); services.TryAddScoped(typeof(IUserAuthenticator<>), typeof(DefaultUserAuthenticator<>)); - services.TryAddScoped(typeof(ISessionOrchestrator<>), typeof(UAuthSessionOrchestrator<>)); + services.TryAddScoped(typeof(ISessionOrchestrator), typeof(UAuthSessionOrchestrator)); services.TryAddScoped(); - services.TryAddScoped(typeof(ISessionQueryService<>), typeof(UAuthSessionQueryService<>)); + services.TryAddScoped(typeof(ISessionQueryService), typeof(UAuthSessionQueryService)); services.TryAddScoped(typeof(IRefreshTokenResolver), typeof(DefaultRefreshTokenResolver)); - services.TryAddScoped(typeof(ISessionRefreshService<>), typeof(DefaultSessionRefreshService<>)); + services.TryAddScoped(typeof(ISessionTouchService), typeof(DefaultSessionTouchService)); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -176,8 +178,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.AddScoped(); - services.AddScoped(typeof(IRefreshTokenValidator<>), typeof(DefaultRefreshTokenValidator<>)); - services.AddScoped(typeof(IRefreshTokenRotationService<>), typeof(RefreshTokenRotationService<>)); + services.AddScoped(typeof(IRefreshTokenValidator), typeof(DefaultRefreshTokenValidator)); + services.AddScoped(); + services.AddScoped(typeof(IRefreshTokenRotationService), typeof(RefreshTokenRotationService)); services.AddSingleton(); services.AddSingleton(); @@ -185,6 +188,14 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddScoped(); services.AddSingleton(); + services.AddScoped(); + + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); // ----------------------------- // ENDPOINTS @@ -199,21 +210,23 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); + // Endpoint handlers - //services.TryAddScoped(typeof(ILoginEndpointHandler), typeof(DefaultLoginEndpointHandler<>)); - services.AddScoped>(); + services.AddScoped>(); services.TryAddScoped(); - services.AddScoped>(); + services.AddScoped(); services.TryAddScoped(); - services.AddScoped>(); + services.AddScoped>(); services.TryAddScoped(); - services.AddScoped>(); + services.AddScoped(); services.TryAddScoped(); + + services.AddScoped>(); + services.TryAddScoped(); //services.TryAddScoped(); - //services.TryAddScoped(); //services.TryAddScoped(); //services.TryAddScoped(); @@ -227,8 +240,8 @@ public static IServiceCollection AddUAuthServerInfrastructure(this IServiceColle services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); // Issuers - services.TryAddScoped(typeof(ISessionIssuer<>), typeof(UAuthSessionIssuer<>)); - services.TryAddScoped(); + services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); + services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); // User service services.TryAddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs index 5eb344c5..657f99b6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs index 93e78b3d..361b3acd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure { @@ -8,6 +8,6 @@ public sealed class TransportCredential public required string Value { get; init; } public string? TenantId { get; init; } - public DeviceInfo? Device { get; init; } + public required DeviceInfo Device { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs index 5a087d77..31f07bac 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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; @@ -28,7 +29,16 @@ public DefaultCredentialResponseWriter( _headerPolicy = headerPolicy; } - public void Write(HttpContext context, CredentialKind kind, string value) + public void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId) + => WriteInternal(context, kind, sessionId.ToString()); + + public void Write(HttpContext context, CredentialKind kind, AccessToken token) + => WriteInternal(context, kind, token.Token); + + public void Write(HttpContext context, CredentialKind kind, RefreshToken token) + => WriteInternal(context, kind, token.Token); + + public void WriteInternal(HttpContext context, CredentialKind kind, string value) { var auth = _authContext.Current; var delivery = ResolveDelivery(auth.Response, kind); @@ -58,9 +68,7 @@ private void WriteCookie(HttpContext context, CredentialKind kind, string value, if (options.Cookie is null) throw new InvalidOperationException($"Cookie options missing for credential '{kind}'."); - var logicalLifetime = ResolveLogicalLifetime(auth, kind); - var cookieOptions = _cookiePolicy.Build(options, auth, logicalLifetime); - + var cookieOptions = _cookiePolicy.Build(options, auth, kind); _cookieManager.Write(context, options.Cookie.Name, value, cookieOptions); } @@ -80,22 +88,4 @@ private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse r CredentialKind.RefreshToken => response.RefreshTokenDelivery, _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; - - private static TimeSpan? ResolveLogicalLifetime(AuthFlowContext auth, CredentialKind kind) - { - // TODO: Move this method to policy on implementing - return kind switch - { - CredentialKind.Session - => auth.EffectiveOptions.Options.Session.IdleTimeout + auth.OriginalOptions.Cookie.Session.Lifetime.IdleBuffer, - - CredentialKind.RefreshToken - => auth.EffectiveOptions.Options.Tokens.RefreshTokenLifetime + auth.OriginalOptions.Cookie.RefreshToken.Lifetime.IdleBuffer, - - CredentialKind.AccessToken - => auth.EffectiveOptions.Options.Tokens.AccessTokenLifetime + auth.OriginalOptions.Cookie.AccessToken.Lifetime.IdleBuffer, - - _ => null - }; - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs new file mode 100644 index 00000000..5a69b57e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class DefaultDeviceContextFactory : IDeviceContextFactory + { + public DeviceContext Create(DeviceInfo device) + { + if (string.IsNullOrWhiteSpace(device.DeviceId.Value)) + return DeviceContext.Anonymous(); + + return DeviceContext.FromDeviceId(device.DeviceId); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs similarity index 59% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultDeviceResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs index 15ef29a4..a8c7cc9a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultDeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs @@ -1,6 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstractions; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; namespace CodeBeam.UltimateAuth.Server.Infrastructure { @@ -10,27 +12,33 @@ public DeviceInfo Resolve(HttpContext context) { var request = context.Request; + var rawDeviceId = ResolveRawDeviceId(context); + DeviceId.TryCreate(rawDeviceId, out var deviceId); + return new DeviceInfo { - DeviceId = ResolveDeviceId(context), + DeviceId = deviceId, Platform = ResolvePlatform(request), - OperatingSystem = null, // optional UA parsing later - Browser = request.Headers.UserAgent.ToString(), - IpAddress = context.Connection.RemoteIpAddress?.ToString(), UserAgent = request.Headers.UserAgent.ToString(), - IsTrusted = null + IpAddress = context.Connection.RemoteIpAddress?.ToString() }; } - private static string ResolveDeviceId(HttpContext context) + + private static string? ResolveRawDeviceId(HttpContext context) { - if (context.Request.Headers.TryGetValue("X-Device-Id", out var header)) + if (context.Request.Headers.TryGetValue("X-UDID", out var header)) return header.ToString(); - if (context.Request.Cookies.TryGetValue("ua_device", out var cookie)) + if (context.Request.HasFormContentType && context.Request.Form.TryGetValue("__uauth_device", out var formValue) && !StringValues.IsNullOrEmpty(formValue)) + { + return formValue.ToString(); + } + + if (context.Request.Cookies.TryGetValue("udid", out var cookie)) return cookie; - return "unknown"; + return null; } private static string? ResolvePlatform(HttpRequest request) @@ -45,5 +53,6 @@ private static string ResolveDeviceId(HttpContext context) return "web"; } + } } 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..6ca4c192 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs @@ -0,0 +1,10 @@ +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/DeviceInfoFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs deleted file mode 100644 index 39b906b7..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DeviceInfoFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public static class DeviceInfoFactory - { - public static DeviceInfo FromHttpContext(HttpContext context) - { - return new DeviceInfo - { - DeviceId = ResolveDeviceId(context), - Platform = context.Request.Headers.UserAgent.ToString(), - UserAgent = context.Request.Headers.UserAgent.ToString() - }; - } - - private static string ResolveDeviceId(HttpContext context) - { - // TODO: cookie / fingerprint / header in future - return context.Request.Headers.UserAgent.ToString(); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs new file mode 100644 index 00000000..bfc20d41 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure.Hub +{ + internal sealed class DefaultHubCredentialResolver : IHubCredentialResolver + { + private readonly IAuthStore _store; + + public DefaultHubCredentialResolver(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)) + return null; + + if (!flow.Payload.TryGet("code_verifier", out string? codeVerifier)) + return null; + + return new HubCredentials + { + AuthorizationCode = authorizationCode, + CodeVerifier = codeVerifier, + ClientProfile = flow.ClientProfile, + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs new file mode 100644 index 00000000..a49409ad --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.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 DefaultHubFlowReader : IHubFlowReader + { + private readonly IAuthStore _store; + private readonly IClock _clock; + + public DefaultHubFlowReader(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, + IsExpired = flow.IsExpired(now), + IsCompleted = flow.IsCompleted, + IsActive = !flow.IsExpired(now) && !flow.IsCompleted + }; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs new file mode 100644 index 00000000..bfbc2ba8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs @@ -0,0 +1,9 @@ +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/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs index 98496b7c..6868542d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -3,9 +3,9 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand> + internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand { - public Task> ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { return issuer.IssueLoginSessionAsync(LoginContext, ct); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs index 5139b34a..0040e5a8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs @@ -3,8 +3,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public interface ISessionCommand + public interface ISessionCommand { - Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct); + 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 index 8cedd4dc..668a9d28 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs @@ -1,8 +1,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure { - internal interface ISessionOrchestrator + internal interface ISessionOrchestrator { - Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default); + Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs index a271c0d7..64a97736 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs @@ -3,16 +3,16 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public interface ISessionQueryService + public interface ISessionQueryService { - Task> ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default); + Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default); - Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); + Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); - Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default); + Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default); - Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); - Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); + Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs index eacdfe7c..47102fb5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs @@ -4,20 +4,20 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class RevokeAllChainsCommand : ISessionCommand + public sealed class RevokeAllChainsCommand : ISessionCommand { - public TUserId UserId { get; } - public ChainId? ExceptChainId { get; } + public UserKey UserKey { get; } + public SessionChainId? ExceptChainId { get; } - public RevokeAllChainsCommand(TUserId userId, ChainId? exceptChainId) + public RevokeAllChainsCommand(UserKey userKey, SessionChainId? exceptChainId) { - UserId = userId; + UserKey = userKey; ExceptChainId = exceptChainId; } - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) { - await issuer.RevokeAllChainsAsync(context.TenantId, UserId, ExceptChainId, context.At, ct); + await issuer.RevokeAllChainsAsync(context.TenantId, 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 index 0815f98a..c8c1a374 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs @@ -4,18 +4,18 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator { - public sealed class RevokeChainCommand : ISessionCommand + public sealed class RevokeChainCommand : ISessionCommand { - public ChainId ChainId { get; } + public SessionChainId ChainId { get; } - public RevokeChainCommand(ChainId chainId) + public RevokeChainCommand(SessionChainId chainId) { ChainId = chainId; } public async Task ExecuteAsync( AuthContext context, - ISessionIssuer issuer, + ISessionIssuer issuer, CancellationToken ct) { await issuer.RevokeChainAsync( diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs index 8aa0702d..4f0d7521 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs @@ -1,25 +1,26 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator { - public sealed class RevokeRootCommand : ISessionCommand + public sealed class RevokeRootCommand : ISessionCommand { - public TUserId UserId { get; } + public UserKey UserKey { get; } - public RevokeRootCommand(TUserId userId) + public RevokeRootCommand(UserKey userKey) { - UserId = userId; + UserKey = userKey; } public async Task ExecuteAsync( AuthContext context, - ISessionIssuer issuer, + ISessionIssuer issuer, CancellationToken ct) { await issuer.RevokeRootAsync( context.TenantId, - UserId, + UserKey, context.At, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs index 3d88afea..86fa7fd4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs @@ -4,9 +4,9 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - internal sealed record RevokeSessionCommand(string? TenantId, AuthSessionId SessionId) : ISessionCommand + internal sealed record RevokeSessionCommand(string? TenantId, AuthSessionId SessionId) : ISessionCommand { - public async Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + public async Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { await issuer.RevokeSessionAsync(TenantId, SessionId, _.At, ct); return Unit.Value; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs index d57b479e..1e52d029 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs @@ -3,9 +3,9 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand> + internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand { - public Task> ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { return issuer.RotateSessionAsync(RotationContext, ct); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs index 784df9b9..2a9c93c9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -4,19 +4,19 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class UAuthSessionOrchestrator : ISessionOrchestrator + public sealed class UAuthSessionOrchestrator : ISessionOrchestrator { private readonly IAuthAuthority _authority; - private readonly ISessionIssuer _issuer; + private readonly ISessionIssuer _issuer; private bool _executed; - public UAuthSessionOrchestrator(IAuthAuthority authority, ISessionIssuer issuer) + public UAuthSessionOrchestrator(IAuthAuthority authority, ISessionIssuer issuer) { _authority = authority; _issuer = issuer; } - public async Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default) + 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."); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs index ea9e1460..9bd03369 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs @@ -6,70 +6,70 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { - public sealed class UAuthSessionQueryService : ISessionQueryService + public sealed class UAuthSessionQueryService : ISessionQueryService { - private readonly ISessionStoreFactory _storeFactory; + private readonly ISessionStoreKernelFactory _storeFactory; private readonly UAuthServerOptions _options; - public UAuthSessionQueryService(ISessionStoreFactory storeFactory, IOptions options) + public UAuthSessionQueryService(ISessionStoreKernelFactory storeFactory, IOptions options) { _storeFactory = storeFactory; _options = options.Value; } - public async Task> ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) + public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) { - var kernel = _storeFactory.Create(context.TenantId); + var kernel = _storeFactory.Create(context.TenantId); - var session = await kernel.GetSessionAsync(context.TenantId,context.SessionId); + var session = await kernel.GetSessionAsync(context.SessionId); if (session is null) - return SessionValidationResult.Invalid(SessionState.NotFound); + return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); var state = session.GetState(context.Now, _options.Session.IdleTimeout); if (state != SessionState.Active) - return SessionValidationResult.Invalid(state); + return SessionValidationResult.Invalid(state, sessionId: session.SessionId, chainId: session.ChainId); - var chain = await kernel.GetChainAsync(context.TenantId, session.ChainId); + var chain = await kernel.GetChainAsync(session.ChainId); if (chain is null || chain.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked); + return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId); - var root = await kernel.GetSessionRootAsync(context.TenantId, session.UserId); + var root = await kernel.GetSessionRootByUserAsync(session.UserKey); if (root is null || root.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked); + return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId, root?.RootId); if (session.SecurityVersionAtCreation != root.SecurityVersion) - return SessionValidationResult.Invalid(SessionState.SecurityMismatch); + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId); // TODO: Implement device id, AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. // Currently this line has error on refresh flow. //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); - return SessionValidationResult.Active(context.TenantId, session, chain, root); + return SessionValidationResult.Active(context.TenantId, session.UserKey, session.SessionId, session.ChainId, root.RootId, session.Claims, boundDeviceId: session.Device.DeviceId); } - public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + public Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetSessionAsync(tenantId, sessionId); + var kernel = _storeFactory.Create(tenantId); + return kernel.GetSessionAsync(sessionId); } - public Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + public Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetSessionsByChainAsync(tenantId, chainId); + var kernel = _storeFactory.Create(tenantId); + return kernel.GetSessionsByChainAsync(chainId); } - public Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + public Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetChainsByUserAsync(tenantId, userId); + var kernel = _storeFactory.Create(tenantId); + return kernel.GetChainsByUserAsync(userKey); } - public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetChainIdBySessionAsync(tenantId, sessionId); + var kernel = _storeFactory.Create(tenantId); + return kernel.GetChainIdBySessionAsync(sessionId); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs new file mode 100644 index 00000000..cef6bdaa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IPkceAuthorizationValidator +{ + PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string codeVerifier, PkceContextSnapshot completionContext, DateTimeOffset now); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs new file mode 100644 index 00000000..605a8179 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +/// +/// 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, + int maxAttempts, + PkceContextSnapshot context) + : base(AuthArtifactType.PkceAuthorizationCode, expiresAt, maxAttempts) + { + 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/Infrastructure/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs new file mode 100644 index 00000000..d479e3a7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs @@ -0,0 +1,70 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class PkceAuthorizationValidator : IPkceAuthorizationValidator +{ + public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string codeVerifier, PkceContextSnapshot completionContext, DateTimeOffset now) + { + // 1️⃣ Expiration + if (artifact.IsExpired(now)) + return PkceValidationResult.Fail(PkceValidationFailureReason.ArtifactExpired); + + // 2️⃣ Attempt limit + if (!artifact.CanAttempt()) + return PkceValidationResult.Fail(PkceValidationFailureReason.MaxAttemptsExceeded); + + // 3️⃣ Context consistency + //if (!IsContextValid(artifact.Context, completionContext)) + //return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch); + + // 4️⃣ Challenge method + if (artifact.ChallengeMethod != PkceChallengeMethod.S256) + return PkceValidationResult.Fail(PkceValidationFailureReason.UnsupportedChallengeMethod); + + // 5️⃣ Verifier check + 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.TenantId, completion.TenantId, StringComparison.Ordinal)) + return false; + + if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal)) + return false; + + if (!string.Equals(original.DeviceId, completion.DeviceId, StringComparison.Ordinal)) + 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 = Base64UrlEncode(hash); + + return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(computedChallenge), Encoding.ASCII.GetBytes(expectedChallenge)); + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs new file mode 100644 index 00000000..d1115964 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class PkceAuthorizeRequest +{ + public string CodeChallenge { get; init; } = default!; + public string ChallengeMethod { get; init; } = default!; + public string? RedirectUri { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs new file mode 100644 index 00000000..39de36f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public enum PkceChallengeMethod +{ + S256 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs new file mode 100644 index 00000000..c826b12a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +/// +/// 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, + string? tenantId, + string? redirectUri, + string? deviceId) + { + ClientProfile = clientProfile; + TenantId = tenantId; + RedirectUri = redirectUri; + DeviceId = deviceId; + } + + /// + /// Client profile resolved at runtime (e.g. BlazorWasm). + /// + public UAuthClientProfile ClientProfile { get; } + + /// + /// Tenant context at the time of authorization. + /// + public string? TenantId { 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 string? DeviceId { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs new file mode 100644 index 00000000..6f3dc072 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public enum PkceValidationFailureReason +{ + None, + ArtifactExpired, + MaxAttemptsExceeded, + UnsupportedChallengeMethod, + InvalidVerifier, + ContextMismatch +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs new file mode 100644 index 00000000..ea7b6051 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +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/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs new file mode 100644 index 00000000..ef3ce7ae --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs @@ -0,0 +1,45 @@ +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.Infrastructure +{ + internal class DefaultRefreshResponsePolicy : IRefreshResponsePolicy + { + public CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) + { + if (flow.EffectiveMode == UAuthMode.PureOpaque) + return CredentialKind.Session; + + if (flow.EffectiveMode == UAuthMode.PureJwt) + return CredentialKind.AccessToken; + + if (!string.IsNullOrWhiteSpace(request.RefreshToken) && request.SessionId == null) + { + return CredentialKind.AccessToken; + } + + if (request.SessionId != null) + { + return CredentialKind.Session; + } + + if (flow.ClientProfile == UAuthClientProfile.Api) + return CredentialKind.AccessToken; + + return CredentialKind.Session; + } + + + public bool WriteRefreshToken(AuthFlowContext flow) + { + if (flow.EffectiveMode != UAuthMode.PureOpaque) + return true; + + return false; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs index 13fe2cfc..f6fc373d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs @@ -6,20 +6,18 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { internal sealed class DefaultRefreshTokenResolver : IRefreshTokenResolver { - private const string DefaultCookieName = "ua_refresh"; + private const string DefaultCookieName = "uar"; private const string BearerPrefix = "Bearer "; private const string RefreshHeaderName = "X-Refresh-Token"; public string? Resolve(HttpContext context) { - // 1️⃣ Cookie (preferred) if (context.Request.Cookies.TryGetValue(DefaultCookieName, out var cookieToken) && !string.IsNullOrWhiteSpace(cookieToken)) { return cookieToken; } - // 2️⃣ Authorization: Bearer if (context.Request.Headers.TryGetValue("Authorization", out StringValues authHeader)) { var value = authHeader.ToString(); @@ -31,7 +29,6 @@ internal sealed class DefaultRefreshTokenResolver : IRefreshTokenResolver } } - // 3️⃣ Explicit header fallback if (context.Request.Headers.TryGetValue(RefreshHeaderName, out var headerToken) && !string.IsNullOrWhiteSpace(headerToken)) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs deleted file mode 100644 index 4090237f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionRefreshService.cs +++ /dev/null @@ -1,56 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - /// - /// - /// - /// - public sealed class DefaultSessionRefreshService : ISessionRefreshService where TUserId : notnull - { - private readonly UAuthServerOptions _options; - private readonly ISessionStore _store; - private readonly ISessionActivityWriter _activityWriter; - - public DefaultSessionRefreshService( - IOptions options, - ISessionStore store, - ISessionActivityWriter activityWriter) - { - _options = options.Value; - _store = store; - _activityWriter = activityWriter; - } - - // 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, DateTimeOffset now, CancellationToken ct = default) - { - if (!validation.IsValid) - return SessionRefreshResult.ReauthRequired(); - - var session = validation.Session; - bool didTouch = false; - var touchInterval = _options.Session.TouchInterval; - - if (touchInterval.HasValue) - { - var elapsed = now - session.LastSeenAt; - - if (elapsed >= touchInterval.Value) - { - var touched = session.Touch(now); - await _activityWriter.TouchAsync(validation.TenantId, touched, ct); - didTouch = true; - } - } - - var primaryToken = PrimaryToken.FromSession(session.SessionId); - // For PureOpaque sessions, we do not issue a new refresh token on refresh. - return SessionRefreshResult.Success(primaryToken, didTouch: didTouch); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs new file mode 100644 index 00000000..c8a6c817 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class DefaultSessionTouchService : ISessionTouchService + { + private readonly ISessionStore _sessionStore; + + public DefaultSessionTouchService(ISessionStore sessionStore) + { + _sessionStore = sessionStore; + } + + // 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) + return SessionRefreshResult.ReauthRequired(); + + //var session = validation.Session; + bool didTouch = false; + + if (policy.TouchInterval.HasValue) + { + //var elapsed = now - session.LastSeenAt; + + //if (elapsed >= policy.TouchInterval.Value) + //{ + // var touched = session.Touch(now); + // await _activityWriter.TouchAsync(validation.TenantId, touched, ct); + // didTouch = true; + //} + + didTouch = await _sessionStore.TouchSessionAsync(validation.SessionId.Value, now, sessionTouchMode, ct); + } + + return SessionRefreshResult.Success(validation.SessionId.Value, didTouch); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs new file mode 100644 index 00000000..8c05919f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface IRefreshResponsePolicy + { + CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); + bool WriteRefreshToken(AuthFlowContext flow); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs similarity index 61% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs index e7e7c2bf..375fc310 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionRefreshService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs @@ -6,8 +6,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure /// Refreshes session lifecycle artifacts. /// Used by PureOpaque and Hybrid modes. /// - public interface ISessionRefreshService : IRefreshService where TUserId : notnull + public interface ISessionTouchService : IRefreshService { - Task RefreshAsync(SessionValidationResult validation, DateTimeOffset now, CancellationToken ct = default); + Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs new file mode 100644 index 00000000..33581937 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using System.Security; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + 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/Infrastructure/Refresh/SessionTouchPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs new file mode 100644 index 00000000..9f4fa8b5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class SessionTouchPolicy + { + public TimeSpan? TouchInterval { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs new file mode 100644 index 00000000..5f852417 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal static class SessionValidationMapper + { + 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.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs index 9caab359..6165f93f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs @@ -20,7 +20,10 @@ public sealed class BearerSessionIdResolver : IInnerSessionIdResolver if (string.IsNullOrWhiteSpace(raw)) return null; - return new AuthSessionId(raw); + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs index b1988ae5..3c905b1c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs @@ -23,9 +23,13 @@ public CookieSessionIdResolver(IOptions options) if (!context.Request.Cookies.TryGetValue(cookieName, out var raw)) return null; - return string.IsNullOrWhiteSpace(raw) - ? null - : new AuthSessionId(raw.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/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs index 0ea9eee4..fe40bc17 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs @@ -21,7 +21,14 @@ public HeaderSessionIdResolver(IOptions options) return null; var raw = values.FirstOrDefault(); - return string.IsNullOrWhiteSpace(raw) ? null : new AuthSessionId(raw); + + 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/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs index c0b1e7f9..bb3cc9e1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs @@ -21,9 +21,14 @@ public QuerySessionIdResolver(IOptions options) return null; var raw = values.FirstOrDefault(); - return string.IsNullOrWhiteSpace(raw) - ? null - : new AuthSessionId(raw.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/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs index 7e9a6d9f..34f76772 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs @@ -8,15 +8,15 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class UAuthUserAccessor : IUserAccessor { - private readonly ISessionStore _sessionStore; - private readonly IUAuthUserStore _userStore; + private readonly ISessionStore _sessionStore; + private readonly IUserIdConverter _userIdConverter; public UAuthUserAccessor( - ISessionStore sessionStore, - IUAuthUserStore userStore) + ISessionStore sessionStore, + IUserIdConverterResolver converterResolver) { _sessionStore = sessionStore; - _userStore = userStore; + _userIdConverter = converterResolver.GetConverter(); } public async Task ResolveAsync(HttpContext context) @@ -37,7 +37,8 @@ public async Task ResolveAsync(HttpContext context) return; } - context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Authenticated(session.UserId); + var userId = _userIdConverter.FromString(session.UserKey.Value); + context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Authenticated(userId); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs index debf9658..3fabb84e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs @@ -15,7 +15,7 @@ public UserAccessorBridge(IServiceProvider services) public async Task ResolveAsync(HttpContext context) { - var accessor = _services.GetRequiredService>(); + var accessor = _services.GetRequiredService>(); await accessor.ResolveAsync(context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs index d4233792..a10a2b19 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs @@ -11,27 +11,27 @@ namespace CodeBeam.UltimateAuth.Server.Issuers { - public sealed class UAuthSessionIssuer : IHttpSessionIssuer + public sealed class UAuthSessionIssuer : IHttpSessionIssuer { + private readonly ISessionStore _sessionStore; private readonly IOpaqueTokenGenerator _opaqueGenerator; - private readonly ISessionStoreFactory _storeFactory; private readonly UAuthServerOptions _options; private readonly IUAuthCookieManager _cookieManager; - public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, ISessionStoreFactory storeFactory, IOptions options, IUAuthCookieManager cookieManager) + public UAuthSessionIssuer(ISessionStore sessionStore, IOpaqueTokenGenerator opaqueGenerator, IOptions options, IUAuthCookieManager cookieManager) { + _sessionStore = sessionStore; _opaqueGenerator = opaqueGenerator; - _storeFactory = storeFactory; _options = options.Value; _cookieManager = cookieManager; } - public Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) + public Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) { return IssueLoginInternalAsync(httpContext: null, context, ct); } - public Task> IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) + public Task IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) { if (httpContext is null) throw new ArgumentNullException(nameof(httpContext)); @@ -39,7 +39,7 @@ public Task> IssueLoginSessionAsync(HttpContext httpConte return IssueLoginInternalAsync(httpContext, context, ct); } - private async Task> IssueLoginInternalAsync(HttpContext? httpContext, AuthenticatedSessionContext context, CancellationToken cancellationToken = default) + private async Task IssueLoginInternalAsync(HttpContext? httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority if (_options.Mode == UAuthMode.PureJwt) @@ -49,6 +49,8 @@ private async Task> IssueLoginInternalAsync(HttpContext? 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); @@ -59,76 +61,46 @@ private async Task> IssueLoginInternalAsync(HttpContext? expiresAt = absoluteExpiry; } - var store = _storeFactory.Create(context.TenantId); - - IssuedSession? issued = null; - - await store.ExecuteAsync(async () => + var session = UAuthSession.Create( + sessionId: sessionId, + tenantId: context.TenantId, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, + now: now, + expiresAt: expiresAt, + claims: context.Claims, + device: context.Device, + metadata: context.Metadata + ); + + var issued = new IssuedSession { - // Root - var root = - await store.GetSessionRootAsync(context.TenantId, context.UserId) - ?? UAuthSessionRoot.Create( - context.TenantId, - context.UserId, - now); - - // Chain - var claimsSnapshot = context.Claims; - - var chain = UAuthSessionChain.Create( - ChainId.New(), - context.TenantId, - context.UserId, - root.SecurityVersion, - claimsSnapshot); - - root = root.AttachChain(chain, now); - - // Session - var session = UAuthSession.Create( - sessionId: new AuthSessionId(opaqueSessionId), - tenantId: context.TenantId, - userId: context.UserId, - chainId: chain.ChainId, - now: now, - expiresAt: expiresAt, - claims: context.Claims, - device: context.DeviceInfo, - metadata: context.Metadata - ); - - // Persist (order is intentional) - await store.SaveSessionRootAsync(context.TenantId, root); - await store.SaveChainAsync(context.TenantId, chain); - await store.SaveSessionAsync(context.TenantId, session); - await store.SetActiveSessionIdAsync( - context.TenantId, - chain.ChainId, - session.SessionId); + Session = session, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }; - issued = new IssuedSession + await _sessionStore.CreateSessionAsync(issued, + new SessionStoreContext { - Session = session, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid - }; - }); - - //if (httpContext is not null) - //{ - // _cookieManager.Issue(httpContext, opaqueSessionId); - //} - - return issued!; + TenantId = context.TenantId, + UserKey = context.UserKey, + ChainId = context.ChainId, + IssuedAt = now, + Device = context.Device + }, + ct + ); + + return issued; } - public Task> RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) + public Task RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) { return RotateInternalAsync(httpContext: null, context, ct); } - public Task> RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) + public Task RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) { if (httpContext is null) throw new ArgumentNullException(nameof(httpContext)); @@ -136,186 +108,71 @@ public Task> RotateSessionAsync(HttpContext httpContext, return RotateInternalAsync(httpContext, context, ct); } - private async Task> RotateInternalAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) + private async Task RotateInternalAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) { var now = context.Now; - var store = _storeFactory.Create(context.TenantId); - IssuedSession? issued = null; + var opaqueSessionId = _opaqueGenerator.Generate(); + if (!AuthSessionId.TryCreate(opaqueSessionId, out var newSessionId)) + throw new InvalidCastException("Can't create opaque session id."); - await store.ExecuteAsync(async () => + var expiresAt = now.Add(_options.Session.Lifetime); + if (_options.Session.MaxLifetime is not null) { - var session = await store.GetSessionAsync( - context.TenantId, - context.CurrentSessionId); - - if (session is null) - throw new SecurityException("Session not found."); - - if (session.IsRevoked || session.ExpiresAt <= now) - throw new SecurityException("Session is no longer valid."); - - var chainId = session.ChainId; - - var chain = await store.GetChainAsync( - context.TenantId, - chainId); - - if (chain is null || chain.IsRevoked) - throw new SecurityException("Session chain is invalid."); - - var opaqueSessionId = _opaqueGenerator.Generate(); - - 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 absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value); + if (absoluteExpiry < expiresAt) + expiresAt = absoluteExpiry; + } - var newSession = UAuthSession.Create( - sessionId: new AuthSessionId(opaqueSessionId), - tenantId: session.TenantId, - userId: session.UserId, - chainId: chain.ChainId, + var issued = new IssuedSession + { + Session = UAuthSession.Create( + sessionId: newSessionId, + tenantId: context.TenantId, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, now: now, expiresAt: expiresAt, - claims: chain.ClaimsSnapshot, - device: session.Device, - metadata: session.Metadata - ); - - await store.SaveSessionAsync(context.TenantId, newSession); - - var rotatedChain = chain.RotateSession(newSession.SessionId); - - await store.SaveChainAsync(context.TenantId, rotatedChain); - await store.SetActiveSessionIdAsync( - context.TenantId, - chain.ChainId, - newSession.SessionId); - - await store.RevokeSessionAsync( - context.TenantId, - session.SessionId, - now); + device: context.Device, + claims: context.Claims, + metadata: context.Metadata + ), + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }; - issued = new IssuedSession + await _sessionStore.RotateSessionAsync(context.CurrentSessionId, issued, + new SessionStoreContext { - Session = newSession, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid - }; - }); - - //if (httpContext is not null) - //{ - // _cookieManager.Write(httpContext, issued!.OpaqueSessionId); - //} - - return issued!; + TenantId = context.TenantId, + UserKey = context.UserKey, + IssuedAt = now, + Device = context.Device, + }, + ct + ); + + return issued; } public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { - var store = _storeFactory.Create(tenantId); - - await store.ExecuteAsync(async () => - { - var session = await store.GetSessionAsync(tenantId, sessionId); - if (session is null) - return; - - if (session.IsRevoked) - return; - - await store.RevokeSessionAsync( - tenantId, - sessionId, - at.UtcDateTime); - }); + await _sessionStore.RevokeSessionAsync(tenantId, sessionId, at, ct ); } - public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { - var store = _storeFactory.Create(tenantId); - - await store.ExecuteAsync(async () => - { - var chain = await store.GetChainAsync(tenantId, chainId); - if (chain is null) - return; - - if (chain.IsRevoked) - return; - - await store.RevokeChainAsync(tenantId, chainId, at.UtcDateTime); - - if (chain.ActiveSessionId is not null) - { - await store.RevokeSessionAsync(tenantId, chain.ActiveSessionId.Value, at.UtcDateTime); - } - }); + await _sessionStore.RevokeChainAsync(tenantId, chainId, at, ct ); } - public async Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) { - var store = _storeFactory.Create(tenantId); - - await store.ExecuteAsync(async () => - { - var root = await store.GetSessionRootAsync(tenantId, userId); - if (root is null) - return; - - foreach (var chain in root.Chains) - { - if (exceptChainId.HasValue && chain.ChainId.Equals(exceptChainId.Value)) - { - continue; - } - - await store.RevokeChainAsync(tenantId, chain.ChainId, at.UtcDateTime); - - if (chain.ActiveSessionId is not null) - { - await store.RevokeSessionAsync(tenantId, chain.ActiveSessionId.Value, at.UtcDateTime); - } - } - - await store.SaveSessionRootAsync(tenantId, root); - }); + await _sessionStore.RevokeAllChainsAsync(tenantId, userKey, exceptChainId, at, ct ); } - public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { - var store = _storeFactory.Create(tenantId); - - await store.ExecuteAsync(async () => - { - var root = await store.GetSessionRootAsync(tenantId, userId); - if (root is null) - return; - - var revokedRoot = root.Revoke(at); - - await store.SaveSessionRootAsync(tenantId, revokedRoot); - - foreach (var chain in root.Chains) - { - await store.RevokeChainAsync(tenantId, chain.ChainId, at); - - if (chain.ActiveSessionId is not null) - { - await store.RevokeSessionAsync( - tenantId, - chain.ActiveSessionId.Value, - at); - } - } - }); + await _sessionStore.RevokeRootAsync(tenantId, userKey, at, ct ); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs index adee7737..ad22a080 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs @@ -18,13 +18,17 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; + private readonly IRefreshTokenStore _refreshTokenStore; + private readonly IUserIdConverterResolver _converterResolver; private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IClock clock) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore,IUserIdConverterResolver converterResolver, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; + _refreshTokenStore = refreshTokenStore; + _converterResolver = converterResolver; _clock = clock; } @@ -36,8 +40,9 @@ public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuan return flow.EffectiveMode switch { + // TODO: Discuss, Hybrid token may be JWT. UAuthMode.PureOpaque or UAuthMode.Hybrid => - Task.FromResult(IssueOpaqueAccessToken(expires, context.SessionId)), + Task.FromResult(IssueOpaqueAccessToken(expires, flow?.Session?.SessionId.ToString())), UAuthMode.SemiHybrid or UAuthMode.PureJwt => @@ -48,23 +53,39 @@ UAuthMode.SemiHybrid or }; } - public Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) + public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { if (flow.EffectiveMode == UAuthMode.PureOpaque) - return Task.FromResult(null); + return null; - var tokens = flow.OriginalOptions.Tokens; - var expires = _clock.UtcNow.Add(tokens.RefreshTokenLifetime); + var expires = _clock.UtcNow.Add(flow.OriginalOptions.Tokens.RefreshTokenLifetime); var raw = _opaqueGenerator.Generate(); var hash = _tokenHasher.Hash(raw); - return Task.FromResult(new RefreshToken + var stored = new StoredRefreshToken + { + TenantId = flow.TenantId, + TokenHash = hash, + UserKey = context.UserKey, + // TODO: Check here again + SessionId = (AuthSessionId)context.SessionId, + ChainId = context.ChainId, + IssuedAt = _clock.UtcNow, + ExpiresAt = expires + }; + + if (persistence == RefreshTokenPersistence.Persist) + { + await _refreshTokenStore.StoreAsync(flow.TenantId, stored, ct); + } + + return new RefreshToken { Token = raw, TokenHash = hash, ExpiresAt = expires - }); + }; } private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) @@ -84,14 +105,14 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken { var claims = new Dictionary { - ["sub"] = context.UserId, + ["sub"] = context.UserKey, ["tenant"] = context.TenantId }; foreach (var kv in context.Claims) claims[kv.Key] = kv.Value; - if (!string.IsNullOrWhiteSpace(context.SessionId)) + if (context.SessionId != null) claims["sid"] = context.SessionId!; if (tokens.AddJwtIdClaim) @@ -99,7 +120,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken var descriptor = new UAuthJwtTokenDescriptor { - Subject = context.UserId, + Subject = context.UserKey, Issuer = tokens.Issuer, Audience = tokens.Audience, IssuedAt = _clock.UtcNow, @@ -116,7 +137,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken Token = jwt, Type = TokenType.Jwt, ExpiresAt = expires, - SessionId = context.SessionId + SessionId = context.SessionId.ToString() }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs deleted file mode 100644 index 047ce87f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Server.Options -{ - public sealed class UAuthHubOptions - { - public string? ClientBaseAddress { get; set; } - - public HashSet AllowedClientOrigins { get; set; } = new(); - - internal UAuthHubOptions Clone() => new() - { - ClientBaseAddress = ClientBaseAddress, - AllowedClientOrigins = new HashSet(AllowedClientOrigins) - }; - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs new file mode 100644 index 00000000..9eefe565 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class UAuthHubServerOptions + { + 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(2); + + public string? LoginPath { get; set; } = "/login"; + + internal UAuthHubServerOptions Clone() => new() + { + ClientBaseAddress = ClientBaseAddress, + AllowedClientOrigins = new HashSet(AllowedClientOrigins), + FlowLifetime = FlowLifetime, + LoginPath = LoginPath + }; + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index be3422fb..1388620f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -90,7 +90,7 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public AuthResponseOptions AuthResponse { get; init; } = new(); - public UAuthHubOptions Hub { get; set; } = new(); + public UAuthHubServerOptions Hub { get; set; } = new(); /// /// Controls how session identifiers are resolved from incoming requests diff --git a/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs new file mode 100644 index 00000000..db6542d6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs @@ -0,0 +1,241 @@ +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.Infrastructure; +using System.ComponentModel.DataAnnotations; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class DefaultRefreshFlowService : IRefreshFlowService + { + private readonly ISessionQueryService _sessionQueries; + private readonly ISessionTouchService _sessionRefresh; + private readonly IRefreshTokenRotationService _tokenRotation; + private readonly IRefreshTokenStore _refreshTokenStore; + private readonly IUserIdConverterResolver _userIdConverterResolver; + + public DefaultRefreshFlowService( + ISessionQueryService sessionQueries, + ISessionTouchService sessionRefresh, + IRefreshTokenRotationService tokenRotation, + IRefreshTokenStore refreshTokenStore, + IUserIdConverterResolver userIdConverterResolver) + { + _sessionQueries = sessionQueries; + _sessionRefresh = sessionRefresh; + _tokenRotation = tokenRotation; + _refreshTokenStore = refreshTokenStore; + _userIdConverterResolver = userIdConverterResolver; + } + + 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 validation = await _sessionQueries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = flow.TenantId, + SessionId = request.SessionId.Value, + Now = request.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, request.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 rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + //if (rotation.Result.RefreshToken is not null) + //{ + // var converter = _userIdConverterResolver.GetConverter(); + + // await _refreshTokenStore.StoreAsync( + // flow.TenantId, + // new StoredRefreshToken + // { + // TokenHash = rotation.Result.RefreshToken.TokenHash, + // UserId = rotation.UserId!, + // SessionId = rotation.SessionId!.Value, + // ChainId = rotation.ChainId, + // ExpiresAt = rotation.Result.RefreshToken.ExpiresAt, + // IssuedAt = request.Now + // }, + // ct); + //} + + 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 validation = await _sessionQueries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = flow.TenantId, + SessionId = request.SessionId.Value, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.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, request.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 validation = await _sessionQueries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = flow.TenantId, + SessionId = request.SessionId.Value, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.Now, + Device = request.Device, + ExpectedSessionId = request.SessionId.Value + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + // ❗ NO SESSION TOUCH HERE + // Session lifetime is fixed in SemiHybrid + + //await StoreRefreshTokenAsync(flow, rotation, request.Now, ct); + + return RefreshFlowResult.Success( + outcome: RefreshOutcome.Rotated, + sessionId: request.SessionId.Value, + accessToken: rotation.Result.AccessToken, + refreshToken: rotation.Result.RefreshToken); + } + + //private async Task StoreRefreshTokenAsync(AuthFlowContext flow, RefreshTokenRotationExecution rotation, DateTimeOffset now, CancellationToken ct) + //{ + // if (rotation.Result.RefreshToken is null) + // return; + + // await _refreshTokenStore.StoreAsync( + // flow.TenantId, + // new StoredRefreshToken + // { + // TokenHash = rotation.Result.RefreshToken.TokenHash, + // UserId = rotation.UserId!, + // SessionId = rotation.SessionId!.Value, + // ChainId = rotation.ChainId, + // ExpiresAt = rotation.Result.RefreshToken.ExpiresAt, + // IssuedAt = now + // }, + // ct); + //} + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs new file mode 100644 index 00000000..663bb9ba --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs @@ -0,0 +1,10 @@ +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/IRefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs index 32bf604c..6bb844b2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs @@ -3,8 +3,8 @@ namespace CodeBeam.UltimateAuth.Server.Services { - public interface IRefreshTokenRotationService + public interface IRefreshTokenRotationService { - Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default); + Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs index 2888ae2e..a0f00e8d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs @@ -11,6 +11,8 @@ 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); @@ -21,14 +23,6 @@ public interface IUAuthFlowService Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default); - Task RefreshSessionAsync(AuthFlowContext flow, SessionRefreshRequest request, CancellationToken ct = default); - Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); - - Task CreatePkceChallengeAsync(PkceCreateRequest request, CancellationToken ct = default); - - Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default); - - Task ConsumePkceAsync(PkceConsumeRequest request, CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs deleted file mode 100644 index c21ffa68..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthTokenService.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Auth; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - /// - /// Issues, refreshes and validates access and refresh tokens. - /// Stateless or hybrid depending on auth mode. - /// - public interface IUAuthTokenService - { - /// - /// Issues access (and optionally refresh) tokens - /// for a validated session. - /// - Task CreateTokensAsync(AuthFlowContext flow, TokenIssueContext context, CancellationToken cancellationToken = default); - - /// - /// Refreshes tokens using a refresh token. - /// - Task RefreshAsync(AuthFlowContext flow, TokenRefreshContext context, CancellationToken cancellationToken = default); - - /// - /// Validates JWT. - /// - Task> ValidateJwtAsync(string token, CancellationToken cancellationToken = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 6435f9ac..e61298fe 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,22 +1,20 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; +using System; namespace CodeBeam.UltimateAuth.Server.Services; -public sealed class RefreshTokenRotationService : IRefreshTokenRotationService +public sealed class RefreshTokenRotationService : IRefreshTokenRotationService { - private readonly IRefreshTokenValidator _validator; - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenValidator _validator; + private readonly IRefreshTokenStore _store; private readonly ITokenIssuer _tokenIssuer; private readonly IClock _clock; - public RefreshTokenRotationService( - IRefreshTokenValidator validator, - IRefreshTokenStore store, - ITokenIssuer tokenIssuer, - IClock clock) + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStore store, ITokenIssuer tokenIssuer, IClock clock) { _validator = validator; _store = store; @@ -24,49 +22,85 @@ public RefreshTokenRotationService( _clock = clock; } - public async Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default) + // 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 now = context.Now; var validation = await _validator.ValidateAsync( - flow.TenantId, - context.RefreshToken, - now, + new RefreshTokenValidationContext + { + TenantId = flow.TenantId, + RefreshToken = context.RefreshToken, + Now = context.Now, + Device = context.Device, + ExpectedSessionId = context.ExpectedSessionId + }, ct); - // ❌ Invalid if (!validation.IsValid) - return RefreshTokenRotationResult.Failed(); + return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; - // 🚨 Reuse detected → nuke from orbit if (validation.IsReuseDetected) { if (validation.ChainId is not null) { - await _store.RevokeByChainAsync(validation.TenantId, validation.ChainId.Value, now, ct); + await _store.RevokeByChainAsync(validation.TenantId, validation.ChainId.Value, context.Now, ct); } else if (validation.SessionId is not null) { - await _store.RevokeBySessionAsync(validation.TenantId, validation.SessionId.Value, now, ct); + await _store.RevokeBySessionAsync(validation.TenantId, validation.SessionId.Value, context.Now, ct); } - return RefreshTokenRotationResult.Failed(); + return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; } + if (validation.UserKey is not UserKey uKey) + { + throw new InvalidOperationException("Validated refresh token does not contain a UserKey."); + } - // ✅ Valid rotation var tokenContext = new TokenIssuanceContext { TenantId = flow.OriginalOptions.MultiTenant.Enabled ? validation.TenantId : null, - UserId = validation.UserId!.ToString()!, - SessionId = validation.SessionId!.Value + UserKey = uKey, + SessionId = validation.SessionId, + ChainId = validation.ChainId }; var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); - var refreshToken = await _tokenIssuer.IssueRefreshTokenAsync(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. + // TODO: Add _store.ExecuteAsync here to wrap RevokeAsync and StoreAsync + await _store.RevokeAsync(validation.TenantId, validation.TokenHash, context.Now, refreshToken.TokenHash, ct); - return RefreshTokenRotationResult.Success(accessToken, refreshToken!); + var stored = new StoredRefreshToken + { + TenantId = flow.TenantId, + TokenHash = refreshToken.TokenHash, + UserKey = uKey, + SessionId = validation.SessionId.Value, + ChainId = validation.ChainId, + IssuedAt = _clock.UtcNow, + ExpiresAt = refreshToken.ExpiresAt + }; + await _store.StoreAsync(validation.TenantId, stored); + + return new RefreshTokenRotationExecution() + { + TenantId = validation.TenantId, + UserKey = validation.UserKey, + SessionId = validation.SessionId, + ChainId = validation.ChainId, + Result = RefreshTokenRotationResult.Success(accessToken, refreshToken) + }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index f1fcee8e..833c7b1d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -1,36 +1,39 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; +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; +using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; -using Microsoft.AspNetCore.Http; -using System.Security.Claims; namespace CodeBeam.UltimateAuth.Server.Services { internal sealed class UAuthFlowService : IUAuthFlowService { + private readonly IAuthFlowContextAccessor _authFlow; private readonly IUAuthUserService _users; - private readonly ISessionOrchestrator _orchestrator; - private readonly ISessionQueryService _queries; + private readonly ISessionOrchestrator _orchestrator; + private readonly ISessionQueryService _queries; private readonly ITokenIssuer _tokens; - private readonly IRefreshTokenValidator _tokenValidator; + private readonly IUserIdConverterResolver _userIdConverterResolver; + private readonly IRefreshTokenValidator _tokenValidator; public UAuthFlowService( + IAuthFlowContextAccessor authFlow, IUAuthUserService users, - ISessionOrchestrator orchestrator, - ISessionQueryService queries, + ISessionOrchestrator orchestrator, + ISessionQueryService queries, ITokenIssuer tokens, - IRefreshTokenValidator tokenValidator) + IUserIdConverterResolver userIdConverterResolver, + IRefreshTokenValidator tokenValidator) { + _authFlow = authFlow; _users = users; _orchestrator = orchestrator; _queries = queries; _tokens = tokens; + _userIdConverterResolver = userIdConverterResolver; _tokenValidator = tokenValidator; } @@ -44,16 +47,6 @@ public Task CompleteMfaAsync(CompleteMfaRequest request, Cancellati throw new NotImplementedException(); } - public Task ConsumePkceAsync(PkceConsumeRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task CreatePkceChallengeAsync(PkceCreateRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) { throw new NotImplementedException(); @@ -62,29 +55,26 @@ public Task ExternalLoginAsync(ExternalLoginRequest request, Cancel public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) { var now = request.At ?? DateTimeOffset.UtcNow; - var device = request.DeviceInfo ?? DeviceInfo.Unknown; var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); if (!auth.Succeeded) return LoginResult.Failed(); - var sessionContext = new AuthenticatedSessionContext + var converter = _userIdConverterResolver.GetConverter(); + var userKey = UserKey.FromString(converter.ToString(auth.UserId!)); + var sessionContext = new AuthenticatedSessionContext { TenantId = request.TenantId, - UserId = auth.UserId!, + UserKey = userKey, Now = now, - DeviceInfo = device, + Device = request.Device, Claims = auth.Claims, - ChainId = request.ChainId + ChainId = request.ChainId, + Metadata = SessionMetadata.Empty // TODO: Check all SessionMetadata.Empty statements }; - var authContext = AuthContext.ForAuthenticatedUser( - request.TenantId, - AuthOperation.Login, - now, - DeviceContext.From(device)); - + var authContext = flow.ToAuthContext(now); var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); bool shouldIssueTokens = request.RequestTokens; @@ -96,13 +86,14 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req var tokenContext = new TokenIssuanceContext { TenantId = request.TenantId, - UserId = auth.UserId!.ToString()!, + UserKey = userKey, SessionId = issuedSession.Session.SessionId, + ChainId = request.ChainId, Claims = auth.Claims.AsDictionary() }; var access = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); - var refresh = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, ct); + var refresh = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct); tokens = new AuthTokens { AccessToken = access, RefreshToken = refresh }; } @@ -110,131 +101,105 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Success(issuedSession.Session.SessionId, tokens); } - public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) { var now = request.At ?? DateTimeOffset.UtcNow; - var authContext = AuthContext.System(request.TenantId, AuthOperation.Revoke,now); - return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.TenantId, request.SessionId), ct); - } + var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); - public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) - { - var now = request.At ?? DateTimeOffset.UtcNow; + if (!auth.Succeeded) + return LoginResult.Failed(); - if (request.CurrentSessionId is null) - throw new InvalidOperationException("CurrentSessionId must be provided for logout-all operation."); + var converter = _userIdConverterResolver.GetConverter(); + var userKey = UserKey.FromString(converter.ToString(auth.UserId!)); + var sessionContext = new AuthenticatedSessionContext + { + TenantId = request.TenantId, + UserKey = userKey, + Now = now, + Device = request.Device, + Claims = auth.Claims, + ChainId = request.ChainId, + Metadata = SessionMetadata.Empty + }; - var currentSessionId = request.CurrentSessionId.Value; + var authContext = flow.ToAuthContext(now); + var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); + + bool shouldIssueTokens = request.RequestTokens; - var validation = await _queries.ValidateSessionAsync( - new SessionValidationContext + AuthTokens? tokens = null; + + if (shouldIssueTokens) + { + var tokenContext = new TokenIssuanceContext { TenantId = request.TenantId, - SessionId = currentSessionId, - Now = now - }, - ct); + UserKey = userKey, + SessionId = issuedSession.Session.SessionId, + ChainId = request.ChainId, + Claims = auth.Claims.AsDictionary() + }; - if (!validation.IsValid || validation.Session is null) - throw new InvalidOperationException("Current session is not valid."); - var userId = validation.Session.UserId; + var effectiveFlow = execution.EffectiveClientProfile is null + ? flow + : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); - ChainId? exceptChainId = null; + var access = await _tokens.IssueAccessTokenAsync(effectiveFlow, tokenContext, ct); - if (request.ExceptCurrent) - { - exceptChainId = await _queries.ResolveChainIdAsync( - request.TenantId, - currentSessionId, - ct); + var refresh = await _tokens.IssueRefreshTokenAsync(effectiveFlow, tokenContext, RefreshTokenPersistence.Persist, ct); - if (exceptChainId is null) - throw new InvalidOperationException("Current session chain could not be resolved."); + tokens = new AuthTokens + { + AccessToken = access, + RefreshToken = refresh + }; } - var authContext = AuthContext.System(request.TenantId, AuthOperation.Revoke, now); - await _orchestrator.ExecuteAsync(authContext, new RevokeAllChainsCommand(userId, exceptChainId), ct); + return LoginResult.Success(issuedSession.Session.SessionId, tokens); } - public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) + public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) { - throw new NotImplementedException(); + var authFlow = _authFlow.Current; + var now = request.At ?? DateTimeOffset.UtcNow; + var authContext = authFlow.ToAuthContext(now); + + return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.TenantId, request.SessionId), ct); } - public async Task RefreshSessionAsync(AuthFlowContext flow, SessionRefreshRequest request, CancellationToken ct = default) + public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) { - var now = DateTimeOffset.UtcNow; - - // Validate refresh token (STORE is authority) - var validation = await _tokenValidator.ValidateAsync(flow.TenantId, request.RefreshToken, now, ct); - - if (!validation.IsValid) - { - if (validation.IsReuseDetected && validation.SessionId is not null) - { - var chainId = await _queries.ResolveChainIdAsync( - request.TenantId, - validation.SessionId.Value, - ct); - - if (chainId is not null) - { - var authContext = AuthContext.System( - request.TenantId, - AuthOperation.Revoke, - now); - - await _orchestrator.ExecuteAsync( - authContext, - new RevokeChainCommand(chainId.Value), - ct); - } - } - - return SessionRefreshResult.ReauthRequired(); - } + var authFlow = _authFlow.Current; + var now = request.At ?? DateTimeOffset.UtcNow; - var session = await _queries.GetSessionAsync(request.TenantId, validation.SessionId!.Value); + if (authFlow.Session is not SessionSecurityContext session) + throw new InvalidOperationException("LogoutAll requires an active session."); - if (session is null) - return SessionRefreshResult.ReauthRequired(); + var authContext = authFlow.ToAuthContext(now); + SessionChainId? exceptChainId = null; - var rotationContext = new SessionRotationContext + if (request.ExceptCurrent) { - TenantId = request.TenantId, - CurrentSessionId = validation.SessionId!.Value, - UserId = validation.UserId!, - Now = now - }; - - var refreshAuthContext = AuthContext.ForAuthenticatedUser(request.TenantId, AuthOperation.Refresh, now, DeviceContext.From(session.Device)); + exceptChainId = session.ChainId; - var issuedSession = await _orchestrator.ExecuteAsync( - refreshAuthContext, - new RotateSessionCommand(rotationContext), - ct); + if (exceptChainId is null) + throw new InvalidOperationException("Current session chain could not be resolved."); + } - var tokenContext = new TokenIssuanceContext + if (authFlow.UserKey is UserKey uaKey) { - TenantId = request.TenantId, - UserId = validation.UserId!.ToString()!, - SessionId = issuedSession.Session.SessionId - }; - - var accessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); - var refreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, ct); - - var primaryToken = PrimaryToken.FromAccessToken(accessToken); - - return SessionRefreshResult.Success(primaryToken, refreshToken); + var command = new RevokeAllChainsCommand(uaKey, exceptChainId); + await _orchestrator.ExecuteAsync(authContext, command, ct); + } + } - public Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default) + public Task ReauthenticateAsync(ReauthRequest 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 index 5848519f..931fd7dd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs @@ -55,9 +55,9 @@ public async Task> ValidateAsync(string var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value; AuthSessionId? sessionId = null; var sid = jwt.GetClaim("sid")?.Value; - if (!string.IsNullOrWhiteSpace(sid)) + if (!AuthSessionId.TryCreate(sid, out AuthSessionId ssid)) { - sessionId = new AuthSessionId(sid); + sessionId = ssid; } return TokenValidationResult.Valid( diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs new file mode 100644 index 00000000..3e05168f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs @@ -0,0 +1,90 @@ +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 CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; + +// TODO: Add wrapper service in client project. Validate method also may add. +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthSessionManager : IUAuthSessionManager + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly ISessionOrchestrator _orchestrator; + private readonly ISessionQueryService _sessionQueryService; + private readonly IClock _clock; + + public UAuthSessionManager(IAuthFlowContextAccessor authFlow, ISessionOrchestrator orchestrator, ISessionQueryService sessionQueryService, IClock clock) + { + _authFlow = authFlow; + _orchestrator = orchestrator; + _sessionQueryService = sessionQueryService; + _clock = clock; + } + + public Task> GetChainsAsync( + string? tenantId, + UserKey userKey) + => _sessionQueryService.GetChainsByUserAsync(tenantId, userKey); + + public Task> GetSessionsAsync( + string? tenantId, + SessionChainId chainId) + => _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId); + + public Task GetSessionAsync( + string? tenantId, + AuthSessionId sessionId) + => _sessionQueryService.GetSessionAsync(tenantId, sessionId); + + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeSessionCommand(tenantId, sessionId); + + return _orchestrator.ExecuteAsync(authContext, command); + } + + public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId) + => _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); + + public Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeAllChainsCommand(userKey, exceptChainId); + + return _orchestrator.ExecuteAsync(authContext, command); + } + + public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeChainCommand(chainId); + + return _orchestrator.ExecuteAsync(authContext, command); + } + + public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeRootCommand(userKey); + + return _orchestrator.ExecuteAsync(authContext, command); + } + + public async Task GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) + { + var chainId = await _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); + + if (chainId is null) + return null; + + var sessions = await _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId.Value); + + return sessions.FirstOrDefault(s => s.SessionId == sessionId); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs deleted file mode 100644 index 4d0e0df8..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs +++ /dev/null @@ -1,112 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - internal sealed class UAuthSessionService : IUAuthSessionService - { - private readonly ISessionOrchestrator _orchestrator; - private readonly ISessionQueryService _sessionQueryService; - - public UAuthSessionService(ISessionOrchestrator orchestrator, ISessionQueryService sessionQueryService) - { - _orchestrator = orchestrator; - _sessionQueryService = sessionQueryService; - } - - public Task> ValidateSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset now) - { - var context = new SessionValidationContext() - { - TenantId = tenantId, - Now = now - }; - - return _sessionQueryService.ValidateSessionAsync(context); - } - - public Task>> GetChainsAsync( - string? tenantId, - TUserId userId) - => _sessionQueryService.GetChainsByUserAsync( - tenantId, - userId); - - public Task>> GetSessionsAsync( - string? tenantId, - ChainId chainId) - => _sessionQueryService.GetSessionsByChainAsync( - tenantId, - chainId); - - public Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId) - => _sessionQueryService.GetSessionAsync( - tenantId, - sessionId); - - public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) - { - var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); - var command = new RevokeSessionCommand(tenantId,sessionId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId) - => _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); - - public Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at) - { - var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); - var command = new RevokeAllChainsCommand(userId, exceptChainId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at) - { - var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); - var command = new RevokeChainCommand(chainId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at) - { - var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); - var command = new RevokeRootCommand(userId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public async Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) - { - var chainId = await _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); - - if (chainId is null) - return null; - - var sessions = await _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId.Value); - - return sessions.FirstOrDefault(s => s.SessionId == sessionId); - } - - public Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken ct = default) - { - var deviceContext = DeviceContext.From(context.DeviceInfo); - var authContext = AuthContext.ForAuthenticatedUser(tenantId, AuthOperation.Login, context.Now, deviceContext); - var command = new CreateLoginSessionCommand(context); - - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs deleted file mode 100644 index 544de760..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs +++ /dev/null @@ -1,61 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Abstactions; -using CodeBeam.UltimateAuth.Server.Auth; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - internal sealed class UAuthTokenService : IUAuthTokenService - { - private readonly ITokenIssuer _issuer; - private readonly IJwtValidator _validator; - private readonly IUserIdConverter _userIdConverter; - - public UAuthTokenService(ITokenIssuer issuer, IJwtValidator validator, IUserIdConverterResolver converterResolver) - { - _issuer = issuer; - _validator = validator; - _userIdConverter = converterResolver.GetConverter(); - } - - public async Task CreateTokensAsync( - AuthFlowContext flow, - TokenIssueContext context, - CancellationToken ct = default) - { - var issuerCtx = ToIssuerContext(context); - - var access = await _issuer.IssueAccessTokenAsync(flow, issuerCtx, ct); - var refresh = await _issuer.IssueRefreshTokenAsync(flow, issuerCtx, ct); - - return new AuthTokens - { - AccessToken = access, - RefreshToken = refresh - }; - } - - public async Task RefreshAsync( - AuthFlowContext flow, - TokenRefreshContext context, - CancellationToken ct = default) - { - throw new NotImplementedException("Refresh flow will be implemented after refresh-token store & validation."); - } - - public async Task> ValidateJwtAsync(string token, CancellationToken ct = default) - => await _validator.ValidateAsync(token, ct); - - private TokenIssuanceContext ToIssuerContext(TokenIssueContext src) - { - return new TokenIssuanceContext - { - UserId = _userIdConverter.ToString(src.Session.UserId), - TenantId = src.TenantId, - SessionId = src.Session.SessionId, - Claims = src.Session.Claims.AsDictionary() - }; - } - - } -} 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/Stores/UAuthSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs index fb8dd61a..d1e541d7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Stores /// Resolves session store kernels from DI and provides them /// to framework-level session stores. /// - public sealed class UAuthSessionStoreFactory : ISessionStoreFactory + public sealed class UAuthSessionStoreFactory : ISessionStoreKernelFactory { private readonly IServiceProvider _provider; @@ -17,16 +17,13 @@ public UAuthSessionStoreFactory(IServiceProvider provider) _provider = provider; } - public ISessionStoreKernel Create(string? tenantId) + public ISessionStoreKernel Create(string? tenantId) { - var kernel = _provider.GetService>(); + var kernel = _provider.GetService(); - if (kernel is null) + if (kernel is ITenantAwareSessionStore tenantAware) { - throw new InvalidOperationException( - "No ISessionStoreKernel registered. " + - "Call AddUltimateAuthServer().AddSessionStoreKernel()." - ); + tenantAware.BindTenant(tenantId); } return kernel; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs index a549909a..0f090eac 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs @@ -2,9 +2,9 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory { - internal sealed class InMemoryCredentialUser : IUser + internal sealed class InMemoryCredentialUser : IUser { - public UserId UserId { get; init; } + public UserKey UserId { get; init; } public string Username { get; init; } public string PasswordHash { get; private set; } = default!; @@ -13,10 +13,10 @@ internal sealed class InMemoryCredentialUser : IUser public bool IsActive { get; init; } = true; - IReadOnlyDictionary? IUser.Claims => null; + IReadOnlyDictionary? IUser.Claims => null; public InMemoryCredentialUser( - UserId userId, + UserKey userId, string username, string passwordHash, long securityVersion = 0, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs index 688fdb94..116896ef 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs @@ -7,8 +7,7 @@ internal static class InMemoryCredentialSeeder { public static IReadOnlyCollection CreateDefaultUsers(IUAuthPasswordHasher passwordHasher) { - var adminUserId = UserId.New(); - + var adminUserId = UserKey.New(); var passwordHash = passwordHasher.Hash("Password!"); var admin = new InMemoryCredentialUser( diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs index 2836606d..7b730c49 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs @@ -5,17 +5,17 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory { - internal sealed class InMemoryUserStore : IUAuthUserStore + internal sealed class InMemoryUserStore : IUAuthUserStore { private readonly ConcurrentDictionary _usersByUsername; - private readonly ConcurrentDictionary _usersById; + private readonly ConcurrentDictionary _usersById; public InMemoryUserStore(IEnumerable seededUsers) { _usersByUsername = new ConcurrentDictionary( StringComparer.OrdinalIgnoreCase); - _usersById = new ConcurrentDictionary(); + _usersById = new ConcurrentDictionary(); foreach (var user in seededUsers) { @@ -24,18 +24,18 @@ public InMemoryUserStore(IEnumerable seededUsers) } } - public Task?> FindByIdAsync( + public Task?> FindByIdAsync( string? tenantId, - UserId userId, + UserKey userId, CancellationToken token = default) { token.ThrowIfCancellationRequested(); _usersById.TryGetValue(userId, out var user); - return Task.FromResult?>(user is { IsActive: true } ? user : null); + return Task.FromResult?>(user is { IsActive: true } ? user : null); } - public Task?> FindByUsernameAsync( + public Task?> FindByUsernameAsync( string? tenantId, string username, CancellationToken ct = default) @@ -43,10 +43,10 @@ public InMemoryUserStore(IEnumerable seededUsers) ct.ThrowIfCancellationRequested(); if (!_usersByUsername.TryGetValue(username, out var user) || user.IsActive is false) - return Task.FromResult?>(null); + return Task.FromResult?>(null); // Core’daki UserRecord’u kullanıyorsun; InMemory tarafı buna map eder. - var record = new UserRecord + var record = new UserRecord { Id = user.UserId, Username = user.Username, @@ -59,10 +59,10 @@ public InMemoryUserStore(IEnumerable seededUsers) IsDeleted = false }; - return Task.FromResult?>(record); + return Task.FromResult?>(record); } - public Task?> FindByLoginAsync( + public Task?> FindByLoginAsync( string? tenantId, string login, CancellationToken token = default) @@ -70,12 +70,12 @@ public InMemoryUserStore(IEnumerable seededUsers) token.ThrowIfCancellationRequested(); _usersByUsername.TryGetValue(login, out var user); - return Task.FromResult?>(user is { IsActive: true } ? user : null); + return Task.FromResult?>(user is { IsActive: true } ? user : null); } public Task GetPasswordHashAsync( string? tenantId, - UserId userId, + UserKey userId, CancellationToken token = default) { token.ThrowIfCancellationRequested(); @@ -88,7 +88,7 @@ public InMemoryUserStore(IEnumerable seededUsers) public Task SetPasswordHashAsync( string? tenantId, - UserId userId, + UserKey userId, string passwordHash, CancellationToken token = default) { @@ -102,7 +102,7 @@ public Task SetPasswordHashAsync( return Task.CompletedTask; } - public Task GetSecurityVersionAsync(string? tenantId, UserId userId, CancellationToken token = default) + public Task GetSecurityVersionAsync(string? tenantId, UserKey userId, CancellationToken token = default) { return Task.FromResult( _usersById.TryGetValue(userId, out var user) @@ -110,7 +110,7 @@ public Task GetSecurityVersionAsync(string? tenantId, UserId userId, Cance : 0L); } - public Task IncrementSecurityVersionAsync(string? tenantId, UserId userId, CancellationToken token = default) + public Task IncrementSecurityVersionAsync(string? tenantId, UserKey userId, CancellationToken token = default) { if (_usersById.TryGetValue(userId, out var user)) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs index b313c260..b6532588 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -22,7 +22,7 @@ public static IServiceCollection AddInMemoryCredentials(this IServiceCollection return InMemoryCredentialSeeder.CreateDefaultUsers(hasher); }); - services.AddSingleton>(sp => + services.AddSingleton>(sp => { var users = sp.GetRequiredService>(); return new InMemoryUserStore(users); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs new file mode 100644 index 00000000..75245e2a --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.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/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs deleted file mode 100644 index c998acd3..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; - -internal sealed class EfCoreSessionActivityWriter : ISessionActivityWriter where TUserId : notnull -{ - private readonly UltimateAuthSessionDbContext _db; - - public EfCoreSessionActivityWriter(UltimateAuthSessionDbContext db) - { - _db = db; - } - - public async Task TouchAsync(string? tenantId, ISession session, CancellationToken ct) - { - var projection = await _db.Sessions - .SingleOrDefaultAsync( - x => x.SessionId == session.SessionId && - x.TenantId == tenantId, - ct); - - if (projection is null) - return; - // TODO: Rethink architecture - var updated = session as UAuthSession - ?? throw new InvalidOperationException("EF Core ActivityWriter requires UAuthSession instance."); - - _db.Sessions.Update(updated.ToProjection()); - await _db.SaveChangesAsync(ct); - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs index 4a69c9c6..59a52927 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs @@ -6,67 +6,75 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class EfCoreSessionStore : ISessionStore +internal sealed class EfCoreSessionStore : ISessionStore { - private readonly EfCoreSessionStoreKernel _kernel; - private readonly UltimateAuthSessionDbContext _db; + private readonly EfCoreSessionStoreKernel _kernel; + private readonly UltimateAuthSessionDbContext _db; - public EfCoreSessionStore(EfCoreSessionStoreKernel kernel, UltimateAuthSessionDbContext db) + public EfCoreSessionStore(EfCoreSessionStoreKernel kernel, UltimateAuthSessionDbContext db) { _kernel = kernel; _db = db; } - public async Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + public async Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { var projection = await _db.Sessions .AsNoTracking() - .Where(x => - x.SessionId == sessionId && - x.TenantId == tenantId) - .SingleOrDefaultAsync(ct); - - if (projection is null) - return null; + .SingleOrDefaultAsync( + x => x.SessionId == sessionId && + x.TenantId == tenantId, + ct); - return projection.ToDomain(); + return projection?.ToDomain(); } - public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { await _kernel.ExecuteAsync(async ct => { var now = ctx.IssuedAt; - var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.TenantId == ctx.TenantId && x.UserId!.Equals(ctx.UserId), ct); + var rootProjection = await _db.Roots + .SingleOrDefaultAsync( + x => x.TenantId == ctx.TenantId && + x.UserKey == ctx.UserKey, + ct); - ISessionRoot root; + ISessionRoot root; if (rootProjection is null) { - root = UAuthSessionRoot.Create(ctx.TenantId, ctx.UserId, now); + root = UAuthSessionRoot.Create(ctx.TenantId, ctx.UserKey, now); _db.Roots.Add(root.ToProjection()); } else { - var chains = await LoadChainsAsync(ctx, ct); - root = rootProjection.ToDomain(chains); - } + var chainProjections = await _db.Chains + .AsNoTracking() + .Where(x => x.RootId == rootProjection.RootId) + .ToListAsync(ct); + root = rootProjection.ToDomain( + chainProjections.Select(c => c.ToDomain()).ToList()); + } - ISessionChain chain; + ISessionChain chain; if (ctx.ChainId is not null) { - var chainProjection = await _db.Chains.SingleAsync(x => x.ChainId == ctx.ChainId.Value, ct); + var chainProjection = await _db.Chains + .SingleAsync(x => x.ChainId == ctx.ChainId.Value, ct); + chain = chainProjection.ToDomain(); } else { - chain = UAuthSessionChain.Create( - ChainId.New(), + chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, ctx.TenantId, - ctx.UserId, + ctx.UserKey, root.SecurityVersion, ClaimsSnapshot.Empty); @@ -74,43 +82,44 @@ await _kernel.ExecuteAsync(async ct => root = root.AttachChain(chain, now); } - var session = UAuthSession.Create( - issued.Session.SessionId, - ctx.TenantId, - ctx.UserId, - chain.ChainId, - now, - issued.Session.ExpiresAt, - ctx.DeviceInfo, - issued.Session.Claims, - metadata: SessionMetadata.Empty - ); + var issuedSession = (UAuthSession)issued.Session; + + if (!issuedSession.ChainId.IsUnassigned) + throw new InvalidOperationException("Issued session already has chain."); + + var session = issuedSession.WithChain(chain.ChainId); _db.Sessions.Add(session.ToProjection()); + var updatedChain = chain.AttachSession(session.SessionId); _db.Chains.Update(updatedChain.ToProjection()); - }, ct); } - public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { await _kernel.ExecuteAsync(async ct => { var now = ctx.IssuedAt; - var oldSessionProjection = await _db.Sessions.SingleOrDefaultAsync( - x => x.SessionId == currentSessionId && - x.TenantId == ctx.TenantId, - ct); + var oldProjection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == currentSessionId && + x.TenantId == ctx.TenantId, + ct); - if (oldSessionProjection is null) + if (oldProjection is null) throw new SecurityException("Session not found."); - var oldSession = oldSessionProjection.ToDomain(); + var oldSession = oldProjection.ToDomain(); - var chainProjection = await _db.Chains.SingleOrDefaultAsync( - x => x.ChainId == oldSession.ChainId, ct); + if (oldSession.IsRevoked || oldSession.ExpiresAt <= now) + throw new SecurityException("Session is no longer valid."); + + var chainProjection = await _db.Chains + .SingleOrDefaultAsync( + x => x.ChainId == oldSession.ChainId, + ct); if (chainProjection is null) throw new SecurityException("Chain not found."); @@ -118,152 +127,174 @@ await _kernel.ExecuteAsync(async ct => var chain = chainProjection.ToDomain(); if (chain.IsRevoked) - throw new SecurityException("Session chain is revoked."); - - var newSession = UAuthSession.Create( - issued.Session.SessionId, - ctx.TenantId, - ctx.UserId, - chain.ChainId, - now, - issued.Session.ExpiresAt, - ctx.DeviceInfo, - issued.Session.Claims, - metadata: SessionMetadata.Empty - ); + throw new SecurityException("Chain is revoked."); - _db.Sessions.Add(newSession.ToProjection()); + var newSession = ((UAuthSession)issued.Session) + .WithChain(chain.ChainId); - var updatedChain = chain.RotateSession(newSession.SessionId); - _db.Chains.Update(updatedChain.ToProjection()); + _db.Sessions.Add(newSession.ToProjection()); - var revokedOldSession = oldSession.Revoke(now); - _db.Sessions.Update(revokedOldSession.ToProjection()); + var rotatedChain = chain.RotateSession(newSession.SessionId); + _db.Chains.Update(rotatedChain.ToProjection()); + var revokedOld = oldSession.Revoke(now); + _db.Sessions.Update(revokedOld.ToProjection()); }, ct); } - public async Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + public async Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default) { + var touched = false; + await _kernel.ExecuteAsync(async ct => { - var rootProjection = await _db.Roots - .SingleOrDefaultAsync( - x => x.TenantId == tenantId && - x.UserId!.Equals(userId), - ct); + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); - if (rootProjection is null) + if (projection is null) return; - var chainProjections = await _db.Chains + var session = projection.ToDomain(); + + if (session.IsRevoked) + return; + + if (mode == SessionTouchMode.IfNeeded && at - session.LastSeenAt < TimeSpan.FromMinutes(1)) + return; + + var updated = session.Touch(at); + _db.Sessions.Update(updated.ToProjection()); + + touched = true; + }, ct); + + return touched; + } + + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + => _kernel.ExecuteAsync(async ct => + { + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId && x.TenantId == tenantId, ct); + + if (projection is null) + return; + + var session = projection.ToDomain(); + + if (session.IsRevoked) + return; + + _db.Sessions.Update(session.Revoke(at).ToProjection()); + }, ct); + + public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var chains = await _db.Chains .Where(x => x.TenantId == tenantId && - x.UserId!.Equals(userId)) + x.UserKey == userKey) .ToListAsync(ct); - foreach (var chainProjection in chainProjections) + foreach (var chainProjection in chains) { - var chain = chainProjection.ToDomain(); - - if (chain.IsRevoked) + if (exceptChainId.HasValue && + chainProjection.ChainId == exceptChainId.Value) continue; - var revokedChain = chain.Revoke(at); - _db.Chains.Update(revokedChain.ToProjection()); + var chain = chainProjection.ToDomain(); + + if (!chain.IsRevoked) + _db.Chains.Update(chain.Revoke(at).ToProjection()); if (chain.ActiveSessionId is not null) { - var sessionProjection = await _db.Sessions - .SingleOrDefaultAsync( - x => x.SessionId == chain.ActiveSessionId && - x.TenantId == tenantId, - ct); + var sessionProjection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); if (sessionProjection is not null) { var session = sessionProjection.ToDomain(); - var revokedSession = session.Revoke(at); - _db.Sessions.Update(revokedSession.ToProjection()); + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); } } } - - var root = rootProjection.ToDomain(chainProjections - .Select(c => c.ToDomain()) - .ToList()); - - var revokedRoot = root.Revoke(at); - _db.Roots.Update(revokedRoot.ToProjection()); - }, ct); } - public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default) - { - await _kernel.ExecuteAsync(async ct => + public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + => _kernel.ExecuteAsync(async ct => { - var chainProjection = await _db.Chains + var projection = await _db.Chains .SingleOrDefaultAsync( x => x.ChainId == chainId && x.TenantId == tenantId, ct); - if (chainProjection is null) + if (projection is null) return; - var chain = chainProjection.ToDomain(); + var chain = projection.ToDomain(); if (chain.IsRevoked) return; - var revokedChain = chain.Revoke(at); - _db.Chains.Update(revokedChain.ToProjection()); + _db.Chains.Update(chain.Revoke(at).ToProjection()); if (chain.ActiveSessionId is not null) { var sessionProjection = await _db.Sessions - .SingleOrDefaultAsync( - x => x.SessionId == chain.ActiveSessionId && - x.TenantId == tenantId, - ct); + .SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); if (sessionProjection is not null) { var session = sessionProjection.ToDomain(); - var revokedSession = session.Revoke(at); - _db.Sessions.Update(revokedSession.ToProjection()); + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); } } - }, ct); - } - public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) - { - await _kernel.ExecuteAsync(async ct => + public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + => _kernel.ExecuteAsync(async ct => { - var sessionProjection = await _db.Sessions + var rootProjection = await _db.Roots .SingleOrDefaultAsync( - x => x.SessionId == sessionId && - x.TenantId == tenantId, + x => x.TenantId == tenantId && + x.UserKey == userKey, ct); - if (sessionProjection is null) + if (rootProjection is null) return; - var session = sessionProjection.ToDomain(); + var chainProjections = await _db.Chains + .Where(x => x.RootId == rootProjection.RootId) + .ToListAsync(ct); - if (session.IsRevoked) - return; + foreach (var chainProjection in chainProjections) + { + var chain = chainProjection.ToDomain(); + _db.Chains.Update(chain.Revoke(at).ToProjection()); - var revokedSession = session.Revoke(at); - _db.Sessions.Update(revokedSession.ToProjection()); + if (chain.ActiveSessionId is not null) + { + var sessionProjection = await _db.Sessions + .SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); + if (sessionProjection is not null) + { + var session = sessionProjection.ToDomain(); + _db.Sessions.Update(session.Revoke(at).ToProjection()); + } + } + } + + var root = rootProjection.ToDomain(chainProjections.Select(c => c.ToDomain()).ToList()); + + _db.Roots.Update(root.Revoke(at).ToProjection()); }, ct); - } - public async Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + public async Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) { var projections = await _db.Sessions .AsNoTracking() @@ -275,19 +306,19 @@ public async Task>> GetSessionsByChainAsync(stri return projections.Select(x => x.ToDomain()).ToList(); } - public async Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + public async Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { var projections = await _db.Chains .AsNoTracking() .Where(x => x.TenantId == tenantId && - x.UserId!.Equals(userId)) + x.UserKey.Equals(userKey)) .ToListAsync(ct); return projections.Select(x => x.ToDomain()).ToList(); } - public async Task?> GetChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + public async Task GetChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) { var projection = await _db.Chains .AsNoTracking() @@ -299,18 +330,18 @@ public async Task>> GetChainsByUserAsync(st return projection?.ToDomain(); } - public async Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + public async Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) { return await _db.Sessions .AsNoTracking() .Where(x => x.SessionId == sessionId && x.TenantId == tenantId) - .Select(x => (ChainId?)x.ChainId) + .Select(x => (SessionChainId?)x.ChainId) .SingleOrDefaultAsync(ct); } - public async Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + public async Task GetActiveSessionIdAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) { return await _db.Chains .AsNoTracking() @@ -321,13 +352,13 @@ public async Task>> GetChainsByUserAsync(st .SingleOrDefaultAsync(ct); } - public async Task?> GetSessionRootAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + public async Task GetSessionRootAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { var rootProjection = await _db.Roots .AsNoTracking() .SingleOrDefaultAsync( x => x.TenantId == tenantId && - x.UserId!.Equals(userId), + x.UserKey!.Equals(userKey), ct); if (rootProjection is null) @@ -337,24 +368,9 @@ public async Task>> GetChainsByUserAsync(st .AsNoTracking() .Where(x => x.TenantId == tenantId && - x.UserId!.Equals(userId)) + x.UserKey!.Equals(userKey)) .ToListAsync(ct); return rootProjection.ToDomain(chainProjections.Select(x => x.ToDomain()).ToList()); } - - - private async Task>> LoadChainsAsync(SessionStoreContext ctx, CancellationToken ct) - { - var chainProjections = await _db.Chains - .AsNoTracking() - .Where(x => - x.TenantId == ctx.TenantId && - x.UserId!.Equals(ctx.UserId)) - .ToListAsync(ct); - - return chainProjections - .Select(x => x.ToDomain()) - .ToList(); - } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs index 5982b753..05b51217 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs @@ -1,18 +1,20 @@ -using Microsoft.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; using System.Data; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class EfCoreSessionStoreKernel + internal sealed class EfCoreSessionStoreKernel : ISessionStoreKernel { - private readonly UltimateAuthSessionDbContext _db; + private readonly UltimateAuthSessionDbContext _db; - public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db) + public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db) { _db = db; } - public async Task ExecuteAsync(Func action, CancellationToken ct) + public async Task ExecuteAsync(Func action, CancellationToken ct = default) { var strategy = _db.Database.CreateExecutionStrategy(); @@ -43,5 +45,202 @@ await strategy.ExecuteAsync(async () => }); } + public async Task GetSessionAsync(AuthSessionId sessionId) + { + var projection = await _db.Sessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.SessionId == sessionId); + + return projection?.ToDomain(); + } + + public async Task SaveSessionAsync(ISession session) + { + var projection = session.ToProjection(); + + var exists = await _db.Sessions + .AnyAsync(x => x.SessionId == session.SessionId); + + if (exists) + _db.Sessions.Update(projection); + else + _db.Sessions.Add(projection); + } + + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + { + var projection = await _db.Sessions + .SingleOrDefaultAsync(x => x.SessionId == sessionId); + + if (projection is null) + return; + + var session = projection.ToDomain(); + if (session.IsRevoked) + return; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + public async Task GetChainAsync(SessionChainId chainId) + { + var projection = await _db.Chains + .AsNoTracking() + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + return projection?.ToDomain(); + } + + public async Task SaveChainAsync(ISessionChain chain) + { + var projection = chain.ToProjection(); + + var exists = await _db.Chains + .AnyAsync(x => x.ChainId == chain.ChainId); + + if (exists) + _db.Chains.Update(projection); + else + _db.Chains.Add(projection); + } + + public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) + { + var projection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + var chain = projection.ToDomain(); + if (chain.IsRevoked) + return; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + + public async Task GetActiveSessionIdAsync(SessionChainId chainId) + { + return await _db.Chains + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .Select(x => x.ActiveSessionId) + .SingleOrDefaultAsync(); + } + + public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) + { + var projection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + projection.ActiveSessionId = sessionId; + _db.Chains.Update(projection); + } + + public async Task GetSessionRootByUserAsync(UserKey userKey) + { + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.UserKey == userKey) + .ToListAsync(); + + return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); + } + + public async Task SaveSessionRootAsync(ISessionRoot root) + { + var projection = root.ToProjection(); + + var exists = await _db.Roots + .AnyAsync(x => x.RootId == root.RootId); + + if (exists) + _db.Roots.Update(projection); + else + _db.Roots.Add(projection); + } + + public async Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) + { + var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (projection is null) + return; + + var root = projection.ToDomain(); + _db.Roots.Update(root.Revoke(at).ToProjection()); + } + + public async Task GetChainIdBySessionAsync(AuthSessionId sessionId) + { + return await _db.Sessions + .AsNoTracking() + .Where(x => x.SessionId == sessionId) + .Select(x => (SessionChainId?)x.ChainId) + .SingleOrDefaultAsync(); + } + + public async Task> GetChainsByUserAsync(UserKey userKey) + { + var projections = await _db.Chains + .AsNoTracking() + .Where(x => x.UserKey == userKey) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task> GetSessionsByChainAsync(SessionChainId chainId) + { + var projections = await _db.Sessions + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task GetSessionRootByIdAsync(SessionRootId rootId) + { + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.RootId == rootId); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.RootId == rootId) + .ToListAsync(); + + return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); + } + + + public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) + { + var projections = await _db.Sessions + .Where(x => x.ExpiresAt <= at && !x.IsRevoked) + .ToListAsync(); + + foreach (var p in projections) + { + var revoked = p.ToDomain().Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + } + } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs new file mode 100644 index 00000000..86770175 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + public sealed class EfCoreSessionStoreKernelFactory : ISessionStoreKernelFactory + { + private readonly IServiceProvider _sp; + + public EfCoreSessionStoreKernelFactory(IServiceProvider sp) + { + _sp = sp; + } + + public ISessionStoreKernel Create(string? tenantId) + { + return _sp.GetRequiredService(); + } + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index fac4658a..d0d5a59f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -2,14 +2,15 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class SessionChainProjection + internal sealed class SessionChainProjection { public long Id { get; set; } - public ChainId ChainId { get; set; } = default!; + public SessionChainId ChainId { get; set; } = default!; + public SessionRootId RootId { get; } public string? TenantId { get; set; } - public TUserId UserId { get; set; } = default!; + public UserKey UserKey { get; set; } public int RotationCount { get; set; } public long SecurityVersionAtCreation { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs index 6698c41d..e2230497 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -2,15 +2,15 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class SessionProjection + internal sealed class SessionProjection { public long Id { get; set; } // EF internal PK public AuthSessionId SessionId { get; set; } = default!; - public ChainId ChainId { get; set; } = default!; + public SessionChainId ChainId { get; set; } = default!; public string? TenantId { get; set; } - public TUserId UserId { get; set; } = default!; + public UserKey UserKey { get; set; } = default!; public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } @@ -21,7 +21,7 @@ internal sealed class SessionProjection public long SecurityVersionAtCreation { get; set; } - public DeviceInfo Device { get; set; } = DeviceInfo.Empty; + public DeviceContext Device { get; set; } public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs index bc4dc81d..c49aae0f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -1,11 +1,13 @@ -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class SessionRootProjection + internal sealed class SessionRootProjection { public long Id { get; set; } - + public SessionRootId RootId { get; set; } public string? TenantId { get; set; } - public TUserId UserId { get; set; } = default!; + public UserKey UserKey { get; set; } public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 62ab304a..b1b1c32e 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -4,12 +4,13 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { internal static class SessionChainProjectionMapper { - public static ISessionChain ToDomain(this SessionChainProjection p) + public static ISessionChain ToDomain(this SessionChainProjection p) { - return UAuthSessionChain.FromProjection( + return UAuthSessionChain.FromProjection( p.ChainId, + p.RootId, p.TenantId, - p.UserId, + p.UserKey, p.RotationCount, p.SecurityVersionAtCreation, p.ClaimsSnapshot, @@ -19,13 +20,13 @@ public static ISessionChain ToDomain(this SessionChainProjecti ); } - public static SessionChainProjection ToProjection(this ISessionChain chain) + public static SessionChainProjection ToProjection(this ISessionChain chain) { - return new SessionChainProjection + return new SessionChainProjection { ChainId = chain.ChainId, TenantId = chain.TenantId, - UserId = chain.UserId, + UserKey = chain.UserKey, RotationCount = chain.RotationCount, SecurityVersionAtCreation = chain.SecurityVersionAtCreation, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index 37cc7554..ed2a371e 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -4,16 +4,12 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { internal static class SessionProjectionMapper { - public static ISession ToDomain(this SessionProjection p) + public static ISession ToDomain(this SessionProjection p) { - var device = p.Device == DeviceInfo.Empty - ? DeviceInfo.Unknown - : p.Device; - - return UAuthSession.FromProjection( + return UAuthSession.FromProjection( p.SessionId, p.TenantId, - p.UserId, + p.UserKey, p.ChainId, p.CreatedAt, p.ExpiresAt, @@ -21,19 +17,19 @@ public static ISession ToDomain(this SessionProjection ToProjection(this ISession s) + public static SessionProjection ToProjection(this ISession s) { - return new SessionProjection + return new SessionProjection { SessionId = s.SessionId, TenantId = s.TenantId, - UserId = s.UserId, + UserKey = s.UserKey, ChainId = s.ChainId, CreatedAt = s.CreatedAt, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index d7224850..e8f0f950 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -4,25 +4,27 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { internal static class SessionRootProjectionMapper { - public static ISessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList> chains) + public static ISessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList? chains = null) { - return UAuthSessionRoot.FromProjection( + return UAuthSessionRoot.FromProjection( + root.RootId, root.TenantId, - root.UserId, + root.UserKey, root.IsRevoked, root.RevokedAt, root.SecurityVersion, - chains, + chains ?? Array.Empty(), root.LastUpdatedAt ); } - public static SessionRootProjection ToProjection(this ISessionRoot root) + public static SessionRootProjection ToProjection(this ISessionRoot root) { - return new SessionRootProjection + return new SessionRootProjection { + RootId = root.RootId, TenantId = root.TenantId, - UserId = root.UserId, + UserKey = root.UserKey, IsRevoked = root.IsRevoked, RevokedAt = root.RevokedAt, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs index 34530586..545bce45 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs @@ -3,12 +3,16 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { + internal sealed class AuthSessionIdConverter : ValueConverter + { + public AuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabase(id), raw => AuthSessionIdEfConverter.FromDatabase(raw)) + { + } + } + internal sealed class NullableAuthSessionIdConverter : ValueConverter { - public NullableAuthSessionIdConverter() - : base( - v => v == null ? null : v.Value, - v => v == null ? null : AuthSessionId.From(v)) + public NullableAuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabaseNullable(id), raw => AuthSessionIdEfConverter.FromDatabaseNullable(raw)) { } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs index 54b9dbd1..2b786a5c 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -8,10 +8,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull { - services.AddDbContext>(configureDb); - services.AddScoped>(); - services.AddScoped, EfCoreSessionStore>(); - services.AddScoped, EfCoreSessionActivityWriter>(); + services.AddDbContext(configureDb); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs index fa94bd6a..6e1b3f55 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs @@ -3,11 +3,11 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore { - internal sealed class UltimateAuthSessionDbContext : DbContext + internal sealed class UltimateAuthSessionDbContext : DbContext { - public DbSet> Roots => Set>(); - public DbSet> Chains => Set>(); - public DbSet> Sessions => Set>(); + public DbSet Roots => Set(); + public DbSet Chains => Set(); + public DbSet Sessions => Set(); public UltimateAuthSessionDbContext(DbContextOptions options) : base(options) { @@ -16,17 +16,17 @@ public UltimateAuthSessionDbContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder b) { - b.Entity>(e => + b.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.RowVersion) .IsRowVersion(); - e.Property(x => x.UserId) + e.Property(x => x.UserKey) .IsRequired(); - e.HasIndex(x => new { x.TenantId, x.UserId }) + e.HasIndex(x => new { x.TenantId, x.UserKey }) .IsUnique(); e.Property(x => x.SecurityVersion) @@ -34,25 +34,33 @@ protected override void OnModelCreating(ModelBuilder b) e.Property(x => x.LastUpdatedAt) .IsRequired(); + + e.Property(x => x.RootId) + .HasConversion( + v => v.Value, + v => SessionRootId.From(v)) + .IsRequired(); + + e.HasIndex(x => new { x.TenantId, x.RootId }); + }); - b.Entity>(e => + b.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.RowVersion) .IsRowVersion(); - e.Property(x => x.UserId) + e.Property(x => x.UserKey) .IsRequired(); - e.HasIndex(x => x.ChainId) - .IsUnique(); + e.HasIndex(x => new { x.TenantId, x.ChainId }).IsUnique(); e.Property(x => x.ChainId) .HasConversion( v => v.Value, - v => ChainId.From(v)) + v => SessionChainId.From(v)) .IsRequired(); e.Property(x => x.ActiveSessionId) @@ -66,28 +74,26 @@ protected override void OnModelCreating(ModelBuilder b) .IsRequired(); }); - b.Entity>(e => + b.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.RowVersion).IsRowVersion(); - e.HasIndex(x => x.SessionId).IsUnique(); - e.HasIndex(x => new { x.ChainId, x.RevokedAt }); + e.HasIndex(x => new { x.TenantId, x.SessionId }).IsUnique(); + e.HasIndex(x => new { x.TenantId, x.ChainId, x.RevokedAt }); e.Property(x => x.SessionId) - .HasConversion( - v => v.Value, - v => AuthSessionId.From(v)) + .HasConversion(new AuthSessionIdConverter()) .IsRequired(); e.Property(x => x.ChainId) .HasConversion( v => v.Value, - v => ChainId.From(v)) + v => SessionChainId.From(v)) .IsRequired(); e.Property(x => x.Device) - .HasConversion(new JsonValueConverter()) + .HasConversion(new JsonValueConverter()) .IsRequired(); e.Property(x => x.Claims) 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 index 10d29026..9351133b 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj @@ -10,6 +10,7 @@ + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs deleted file mode 100644 index 3a4639db..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/IMemorySessionStoreKernel.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Collections.Concurrent; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Sessions.InMemory; - -internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel -{ - private readonly SemaphoreSlim _tx = new(1, 1); - - private readonly ConcurrentDictionary> _sessions = new(); - private readonly ConcurrentDictionary> _chains = new(); - private readonly ConcurrentDictionary> _roots = new(); - private readonly ConcurrentDictionary _activeSessions = new(); - - public async Task ExecuteAsync(Func action) - { - await _tx.WaitAsync(); - try - { - await action(); - } - finally - { - _tx.Release(); - } - } - - public Task?> GetSessionAsync(string? _, AuthSessionId sessionId) - => Task.FromResult( - _sessions.TryGetValue(sessionId, out var s) ? s : null); - - public Task SaveSessionAsync(string? _, ISession session) - { - _sessions[session.SessionId] = session; - return Task.CompletedTask; - } - - public Task RevokeSessionAsync(string? _, AuthSessionId sessionId, DateTimeOffset at) - { - if (_sessions.TryGetValue(sessionId, out var session)) - { - _sessions[sessionId] = session.Revoke(at); - } - return Task.CompletedTask; - } - - public Task>> GetSessionsByChainAsync(string? _, ChainId chainId) - { - var result = _sessions.Values - .Where(s => s.ChainId == chainId) - .ToList(); - - return Task.FromResult>>(result); - } - - public Task?> GetChainAsync(string? _, ChainId chainId) - => Task.FromResult( - _chains.TryGetValue(chainId, out var c) ? c : null); - - public Task SaveChainAsync(string? _, ISessionChain chain) - { - _chains[chain.ChainId] = chain; - return Task.CompletedTask; - } - - public Task RevokeChainAsync(string? _, ChainId chainId, DateTimeOffset at) - { - if (_chains.TryGetValue(chainId, out var chain)) - { - _chains[chainId] = chain.Revoke(at); - } - return Task.CompletedTask; - } - - public Task GetActiveSessionIdAsync(string? _, ChainId chainId) - { - return Task.FromResult( - _activeSessions.TryGetValue(chainId, out var id) - ? id - : null - ); - } - - public Task SetActiveSessionIdAsync(string? _, ChainId chainId, AuthSessionId sessionId) - { - _activeSessions[chainId] = sessionId; - return Task.CompletedTask; - } - - public Task>> GetChainsByUserAsync(string? _, TUserId userId) - { - if (!_roots.TryGetValue(userId, out var root)) - return Task.FromResult>>(Array.Empty>()); - - return Task.FromResult>>(root.Chains.ToList()); - } - - public Task?> GetSessionRootAsync(string? _, TUserId userId) - => Task.FromResult(_roots.TryGetValue(userId, out var r) ? r : null); - - public Task SaveSessionRootAsync(string? _, ISessionRoot root) - { - _roots[root.UserId] = root; - return Task.CompletedTask; - } - - public Task RevokeSessionRootAsync(string? _, TUserId userId, DateTimeOffset at) - { - if (_roots.TryGetValue(userId, out var root)) - { - _roots[userId] = root.Revoke(at); - } - return Task.CompletedTask; - } - - public Task DeleteExpiredSessionsAsync(string? _, DateTimeOffset now) - { - foreach (var kvp in _sessions) - { - var session = kvp.Value; - - if (session.ExpiresAt <= now) - { - _sessions.TryGetValue(kvp.Key, out var existing); - - if (existing is not null) - { - _sessions.TryUpdate( - kvp.Key, - existing.Revoke(now), - existing); - } - } - } - - return Task.CompletedTask; - } - - public Task GetChainIdBySessionAsync(string? _, AuthSessionId sessionId) - { - if (_sessions.TryGetValue(sessionId, out var session)) - return Task.FromResult(session.ChainId); - - return Task.FromResult(null); - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs deleted file mode 100644 index cd7fe8b9..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionActivityWriter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Sessions.InMemory -{ - internal sealed class InMemorySessionActivityWriter : ISessionActivityWriter where TUserId : notnull - { - private readonly ISessionStoreFactory _factory; - - public InMemorySessionActivityWriter(ISessionStoreFactory factory) - { - _factory = factory; - } - - public Task TouchAsync(string? tenantId, ISession session, CancellationToken ct) - { - var kernel = _factory.Create(tenantId); - return kernel.SaveSessionAsync(tenantId, session); - } - } - -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index b00b646b..ed5f958f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -1,170 +1,154 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; using System.Security; -namespace CodeBeam.UltimateAuth.Sessions.InMemory; - -public sealed class InMemorySessionStore : ISessionStore +public sealed class InMemorySessionStore : ISessionStore { - private readonly ISessionStoreFactory _factory; + private readonly ISessionStoreKernelFactory _factory; + private readonly UAuthServerOptions _options; - public InMemorySessionStore(ISessionStoreFactory factory) + public InMemorySessionStore(ISessionStoreKernelFactory factory, IOptions options) { _factory = factory; + _options = options.Value; } - private ISessionStoreKernel Kernel(string? tenantId) - => _factory.Create(tenantId); + private ISessionStoreKernel Kernel(string? tenantId) + => _factory.Create(tenantId); - public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) - => Kernel(tenantId).GetSessionAsync(tenantId, sessionId); + public Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + => Kernel(tenantId).GetSessionAsync(sessionId); - public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { var k = Kernel(ctx.TenantId); - await k.ExecuteAsync(async () => + await k.ExecuteAsync(async (ct) => { var now = ctx.IssuedAt; - // Root - var root = - await k.GetSessionRootAsync(ctx.TenantId, ctx.UserId) - ?? UAuthSessionRoot.Create( - ctx.TenantId, - ctx.UserId, - now); - - // Chain - ISessionChain chain; + var root = await k.GetSessionRootByUserAsync(ctx.UserKey) ?? UAuthSessionRoot.Create(ctx.TenantId, ctx.UserKey, now); + ISessionChain chain; if (ctx.ChainId is not null) { - chain = await k.GetChainAsync(ctx.TenantId, ctx.ChainId.Value) - ?? throw new InvalidOperationException("Chain not found."); + chain = await k.GetChainAsync(ctx.ChainId.Value) ?? throw new InvalidOperationException("Chain not found."); } else { - chain = UAuthSessionChain.Create( - ChainId.New(), + chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, ctx.TenantId, - ctx.UserId, + ctx.UserKey, root.SecurityVersion, ClaimsSnapshot.Empty); root = root.AttachChain(chain, now); } - // Session - var session = UAuthSession.Create( - issued.Session.SessionId, - ctx.TenantId, - ctx.UserId, - chain.ChainId, - now, - issued.Session.ExpiresAt, - ctx.DeviceInfo, - issued.Session.Claims, - metadata: null); - - await k.SaveSessionRootAsync(ctx.TenantId, root); - await k.SaveChainAsync(ctx.TenantId, chain); - await k.SaveSessionAsync(ctx.TenantId, session); - await k.SetActiveSessionIdAsync( - ctx.TenantId, - chain.ChainId, - session.SessionId); - }); + var session = issued.Session; + + if (!session.ChainId.IsUnassigned) + { + throw new InvalidOperationException("Issued session already has a chain assigned."); + } + + session = session.WithChain(chain.ChainId); + + // Persist (order intentional) + await k.SaveSessionRootAsync(root); + await k.SaveChainAsync(chain); + await k.SaveSessionAsync(session); + await k.SetActiveSessionIdAsync(chain.ChainId, session.SessionId); + }, ct); } - public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { var k = Kernel(ctx.TenantId); - await k.ExecuteAsync(async () => + await k.ExecuteAsync(async (ct) => { var now = ctx.IssuedAt; - var old = await k.GetSessionAsync(ctx.TenantId, currentSessionId) + var old = await k.GetSessionAsync(currentSessionId) ?? throw new SecurityException("Session not found."); - var chain = await k.GetChainAsync(ctx.TenantId, old.ChainId) + if (old.IsRevoked || old.ExpiresAt <= now) + throw new SecurityException("Session is no longer valid."); + + var chain = await k.GetChainAsync(old.ChainId) ?? throw new SecurityException("Chain not found."); - var newSession = UAuthSession.Create( - issued.Session.SessionId, - ctx.TenantId, - ctx.UserId, - chain.ChainId, - now, - issued.Session.ExpiresAt, - ctx.DeviceInfo, - issued.Session.Claims, - metadata: null); - - await k.SaveSessionAsync(ctx.TenantId, newSession); - await k.SetActiveSessionIdAsync( - ctx.TenantId, - chain.ChainId, - newSession.SessionId); - - await k.RevokeSessionAsync( - ctx.TenantId, - currentSessionId, - now); - }); - } + if (chain.IsRevoked) + throw new SecurityException("Chain is revoked."); - public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) - => Kernel(tenantId).RevokeSessionAsync(tenantId, sessionId, at); + var newSession = ((UAuthSession)issued.Session).WithChain(chain.ChainId); - public async Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + await k.SaveSessionAsync(newSession); + await k.SetActiveSessionIdAsync(chain.ChainId, newSession.SessionId); + await k.RevokeSessionAsync(old.SessionId, now); + }, ct); + } + + public async Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default) { - var k = Kernel(tenantId); + var k = Kernel(null); + bool touched = false; - await k.ExecuteAsync(async () => + await k.ExecuteAsync(async (ct) => { - var root = await k.GetSessionRootAsync(tenantId, userId); - if (root is null) + var session = await k.GetSessionAsync(sessionId); + if (session is null || session.IsRevoked) return; - foreach (var chain in root.Chains) + if (mode == SessionTouchMode.IfNeeded) { - await k.RevokeChainAsync(tenantId, chain.ChainId, at); - - if (chain.ActiveSessionId is not null) - { - await k.RevokeSessionAsync( - tenantId, - chain.ActiveSessionId.Value, - at); - } + var elapsed = at - session.LastSeenAt; + if (elapsed < _options.Session.TouchInterval) + return; } - await k.RevokeSessionRootAsync(tenantId, userId, at); - }); + var updated = session.Touch(at); + await k.SaveSessionAsync(updated); + + touched = true; + }, ct); + + return touched; } - public async Task RevokeChainAsync(string? tenantId,ChainId chainId, DateTimeOffset at, CancellationToken ct = default) + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + => Kernel(tenantId).RevokeSessionAsync(sessionId, at); + + public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) { var k = Kernel(tenantId); - await k.ExecuteAsync(async () => + await k.ExecuteAsync(async (ct) => { - var chain = await k.GetChainAsync(tenantId, chainId); - if (chain is null) - return; + var chains = await k.GetChainsByUserAsync(userKey); - await k.RevokeChainAsync(tenantId, chainId, at); - - if (chain.ActiveSessionId is not null) + foreach (var chain in chains) { - await k.RevokeSessionAsync( - tenantId, - chain.ActiveSessionId.Value, - at); + if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) + continue; + + await k.RevokeChainAsync(chain.ChainId, at); + + if (chain.ActiveSessionId is not null) + await k.RevokeSessionAsync(chain.ActiveSessionId.Value, at); } - }); + }, ct); } + + public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + => Kernel(tenantId).RevokeChainAsync(chainId, at); + + public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + => Kernel(tenantId).RevokeSessionRootAsync(userKey, at); } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs index 11285eda..157bfd8f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs @@ -3,19 +3,22 @@ namespace CodeBeam.UltimateAuth.Sessions.InMemory { - public sealed class InMemorySessionStoreFactory : ISessionStoreFactory + public sealed class InMemorySessionStoreFactory : ISessionStoreKernelFactory { private readonly ConcurrentDictionary _stores = new(); - public ISessionStoreKernel Create(string? tenantId) + public ISessionStoreKernel Create(string? tenantId) { var key = tenantId ?? "__single__"; - var store = _stores.GetOrAdd( - key, - _ => new InMemorySessionStoreKernel()); + var store = _stores.GetOrAdd(key, _ => + { + var k = new InMemorySessionStoreKernel(); + k.BindTenant(tenantId); + return k; + }); - return (ISessionStoreKernel)store; + return (ISessionStoreKernel)store; } } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs new file mode 100644 index 00000000..3aaeee4c --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs @@ -0,0 +1,139 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Collections.Concurrent; + +internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel, ITenantAwareSessionStore +{ + private readonly SemaphoreSlim _tx = new(1, 1); + + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _chains = new(); + private readonly ConcurrentDictionary _roots = new(); + private readonly ConcurrentDictionary _activeSessions = new(); + + public string? TenantId { get; private set; } + + public void BindTenant(string? tenantId) + { + TenantId = tenantId ?? "__single__"; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + try + { + await action(ct); + } + finally + { + _tx.Release(); + } + } + + public Task GetSessionAsync(AuthSessionId sessionId) + => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); + + public Task SaveSessionAsync(ISession session) + { + _sessions[session.SessionId] = session; + return Task.CompletedTask; + } + + public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + _sessions[sessionId] = session.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetChainAsync(SessionChainId chainId) + => Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); + + public Task SaveChainAsync(ISessionChain chain) + { + _chains[chain.ChainId] = chain; + return Task.CompletedTask; + } + + public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) + { + if (_chains.TryGetValue(chainId, out var chain)) + { + _chains[chainId] = chain.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetActiveSessionIdAsync(SessionChainId chainId) + => Task.FromResult(_activeSessions.TryGetValue(chainId, out var id) ? id : null); + + public Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) + { + _activeSessions[chainId] = sessionId; + return Task.CompletedTask; + } + + public Task GetSessionRootByUserAsync(UserKey userKey) + => Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); + + public Task GetSessionRootByIdAsync(SessionRootId rootId) + => Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); + + public Task SaveSessionRootAsync(ISessionRoot root) + { + _roots[root.UserKey] = root; + return Task.CompletedTask; + } + + public Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) + { + if (_roots.TryGetValue(userKey, out var root)) + { + _roots[userKey] = root.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetChainIdBySessionAsync(AuthSessionId sessionId) + { + if (_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(session.ChainId); + + return Task.FromResult(null); + } + + public Task> GetChainsByUserAsync(UserKey userKey) + { + if (!_roots.TryGetValue(userKey, out var root)) + return Task.FromResult>(Array.Empty()); + + return Task.FromResult>(root.Chains.ToList()); + } + + public Task> GetSessionsByChainAsync(SessionChainId chainId) + { + var result = _sessions.Values + .Where(s => s.ChainId == chainId) + .ToList(); + + return Task.FromResult>(result); + } + + public Task DeleteExpiredSessionsAsync(DateTimeOffset at) + { + foreach (var kvp in _sessions) + { + var session = kvp.Value; + + if (session.ExpiresAt <= at) + { + _sessions[kvp.Key] = session.Revoke(at); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs index 70af9b5a..c12a8157 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -7,9 +7,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) { - services.AddSingleton(); - services.AddScoped(typeof(ISessionStore<>), typeof(InMemorySessionStore<>)); - services.AddScoped(typeof(ISessionActivityWriter<>), typeof(InMemorySessionActivityWriter<>)); + services.AddSingleton(); + // TODO: Discuss it to be singleton or scoped + services.AddScoped(); return services; } } 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/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs index 3753ad00..05dc0c1a 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs @@ -3,23 +3,16 @@ using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; -internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore +internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore { private readonly UltimateAuthTokenDbContext _db; - private readonly IUserIdConverter _converter; - public EfCoreRefreshTokenStore( - UltimateAuthTokenDbContext db, - IUserIdConverterResolver converters) + public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, IUserIdConverterResolver converters) { _db = db; - _converter = converters.GetConverter(); } - public async Task StoreAsync( - string? tenantId, - StoredRefreshToken token, - CancellationToken ct = default) + public async Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default) { if (token.TenantId != tenantId) throw new InvalidOperationException("TenantId mismatch between context and token."); @@ -28,8 +21,8 @@ public async Task StoreAsync( { TenantId = tenantId, TokenHash = token.TokenHash, - UserId = _converter.ToString(token.UserId), - SessionId = token.SessionId.Value, + UserKey = token.UserKey, + SessionId = token.SessionId, ChainId = token.ChainId.Value, IssuedAt = token.IssuedAt, ExpiresAt = token.ExpiresAt @@ -38,10 +31,7 @@ public async Task StoreAsync( await _db.SaveChangesAsync(ct); } - public async Task?> FindByHashAsync( - string? tenantId, - string tokenHash, - CancellationToken ct = default) + public async Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default) { var e = await _db.RefreshTokens .AsNoTracking() @@ -53,76 +43,63 @@ public async Task StoreAsync( if (e is null) return null; - return new StoredRefreshToken + return new StoredRefreshToken { TenantId = e.TenantId, TokenHash = e.TokenHash, - UserId = _converter.FromString(e.UserId), - SessionId = new AuthSessionId(e.SessionId), - ChainId = new ChainId(e.ChainId), + UserKey = e.UserKey, + SessionId = e.SessionId, + ChainId = e.ChainId, IssuedAt = e.IssuedAt, ExpiresAt = e.ExpiresAt, RevokedAt = e.RevokedAt }; } - public Task RevokeAsync( - string? tenantId, - string tokenHash, - DateTimeOffset revokedAt, - CancellationToken ct = default) - => _db.RefreshTokens - .Where(x => - x.TokenHash == tokenHash && - x.TenantId == tenantId && - x.RevokedAt == null) - .ExecuteUpdateAsync( - x => x.SetProperty(t => t.RevokedAt, revokedAt), + public Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + { + var query = _db.RefreshTokens + .Where(x => + x.TokenHash == tokenHash && + x.TenantId == tenantId && + x.RevokedAt == null); + + if (replacedByTokenHash == 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( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) => _db.RefreshTokens .Where(x => x.TenantId == tenantId && x.SessionId == sessionId.Value && x.RevokedAt == null) - .ExecuteUpdateAsync( - x => x.SetProperty(t => t.RevokedAt, revokedAt), - ct); + .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - public Task RevokeByChainAsync( - string? tenantId, - ChainId chainId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) => _db.RefreshTokens .Where(x => x.TenantId == tenantId && - x.ChainId == chainId.Value && + x.ChainId == chainId && x.RevokedAt == null) - .ExecuteUpdateAsync( - x => x.SetProperty(t => t.RevokedAt, revokedAt), - ct); + .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - public Task RevokeAllForUserAsync( - string? tenantId, - TUserId userId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { - var uid = _converter.ToString(userId); return _db.RefreshTokens .Where(x => x.TenantId == tenantId && - x.UserId == uid && + x.UserKey == userKey && x.RevokedAt == null) - .ExecuteUpdateAsync( - x => x.SetProperty(t => t.RevokedAt, revokedAt), - ct); + .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index def05c6a..14a759a2 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -9,9 +9,11 @@ internal sealed class RefreshTokenProjection public string? TenantId { get; set; } public string TokenHash { get; set; } = default!; - public string UserId { get; set; } = default!; - public string SessionId { get; set; } = default!; - public ChainId ChainId { get; set; } = default!; + public UserKey UserKey { get; set; } = default!; + public AuthSessionId SessionId { get; set; } = default!; + public SessionChainId ChainId { get; set; } = default!; + + public string? ReplacedByTokenHash { get; set; } public DateTimeOffset IssuedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs index 9dc2fe31..243d161c 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -9,7 +9,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) { services.AddDbContext(configureDb); - services.AddScoped(typeof(IRefreshTokenStore<>), typeof(EfCoreRefreshTokenStore<>)); + services.AddScoped(typeof(IRefreshTokenStore), typeof(EfCoreRefreshTokenStore)); return services; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs index 59f13471..7b958e2d 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; @@ -15,9 +14,6 @@ public UltimateAuthTokenDbContext(DbContextOptions o protected override void OnModelCreating(ModelBuilder b) { - // ------------------------------------------------- - // REFRESH TOKEN - // ------------------------------------------------- b.Entity(e => { e.HasKey(x => x.Id); @@ -31,16 +27,15 @@ protected override void OnModelCreating(ModelBuilder b) e.HasIndex(x => new { x.TenantId, x.TokenHash }) .IsUnique(); - e.HasIndex(x => new { x.TenantId, x.UserId }); + e.HasIndex(x => new { x.TenantId, x.UserKey }); e.HasIndex(x => new { x.TenantId, x.SessionId }); e.HasIndex(x => new { x.TenantId, x.ChainId }); + e.HasIndex(x => new { x.TenantId, x.ExpiresAt }); + e.HasIndex(x => new { x.TenantId, x.ReplacedByTokenHash }); e.Property(x => x.ExpiresAt).IsRequired(); }); - // ------------------------------------------------- - // REVOKED JTI - // ------------------------------------------------- b.Entity(e => { e.HasKey(x => x.Id); diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index a4744064..3d4b09b5 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -4,63 +4,45 @@ namespace CodeBeam.UltimateAuth.Tokens.InMemory; -public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore +public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore { - private static string NormalizeTenant(string? tenantId) - => tenantId ?? "__default__"; + private static string NormalizeTenant(string? tenantId) => tenantId ?? "__default__"; - private readonly ConcurrentDictionary> _tokens - = new(); + private readonly ConcurrentDictionary _tokens = new(); - public Task StoreAsync( - string? tenantId, - StoredRefreshToken token, - CancellationToken ct = default) + public Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default) { - var key = new TokenKey( - NormalizeTenant(tenantId), - token.TokenHash); + var key = new TokenKey(NormalizeTenant(tenantId), token.TokenHash); _tokens[key] = token; return Task.CompletedTask; } - public Task?> FindByHashAsync( - string? tenantId, - string tokenHash, - CancellationToken ct = default) + public Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default) { - var key = new TokenKey( - NormalizeTenant(tenantId), - tokenHash); + var key = new TokenKey(NormalizeTenant(tenantId), tokenHash); _tokens.TryGetValue(key, out var token); return Task.FromResult(token); } - public Task RevokeAsync( - string? tenantId, - string tokenHash, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { - var key = new TokenKey( - NormalizeTenant(tenantId), - tokenHash); + var key = new TokenKey(NormalizeTenant(tenantId), tokenHash); if (_tokens.TryGetValue(key, out var token) && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[key] = token with + { + RevokedAt = revokedAt, + ReplacedByTokenHash = replacedByTokenHash + }; } return Task.CompletedTask; } - public Task RevokeBySessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) { var tenant = NormalizeTenant(tenantId); @@ -77,11 +59,7 @@ public Task RevokeBySessionAsync( return Task.CompletedTask; } - public Task RevokeByChainAsync( - string? tenantId, - ChainId chainId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) { var tenant = NormalizeTenant(tenantId); @@ -98,18 +76,14 @@ public Task RevokeByChainAsync( return Task.CompletedTask; } - public Task RevokeAllForUserAsync( - string? tenantId, - TUserId userId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { var tenant = NormalizeTenant(tenantId); foreach (var (key, token) in _tokens) { if (key.TenantId == tenant && - EqualityComparer.Default.Equals(token.UserId, userId) && + token.UserKey == userKey && !token.IsRevoked) { _tokens[key] = token with { RevokedAt = revokedAt }; diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs index 869d09e5..4716253e 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemoryTokens(this IServiceCollection services) { - services.AddScoped(typeof(IRefreshTokenStore<>), typeof(InMemoryRefreshTokenStore<>)); + services.AddSingleton(typeof(IRefreshTokenStore), typeof(InMemoryRefreshTokenStore)); return services; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 2bbcda1d..145cd305 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -1,36 +1,38 @@  - - net10.0 - enable - enable - false - + + 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 index e2d82146..c053640b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs @@ -2,21 +2,92 @@ namespace CodeBeam.UltimateAuth.Tests.Unit; -public class AuthSessionIdTests +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 Cannot_create_empty_session_id() + public void TryCreate_returns_false_for_empty_string() { - Assert.Throws(() => new AuthSessionId(string.Empty)); + 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() { - var id1 = new AuthSessionId("abc"); - var id2 = new AuthSessionId("abc"); + 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/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs new file mode 100644 index 00000000..ad919a4e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -0,0 +1,116 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tokens.InMemory; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Core +{ + public sealed class RefreshTokenValidatorTests + { + private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; + + private static DefaultRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStore store) + { + return new DefaultRefreshTokenValidator(store, 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 store = new InMemoryRefreshTokenStore(); + var validator = CreateValidator(store); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + TenantId = null, + RefreshToken = "non-existing", + Now = DateTimeOffset.UtcNow, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Reuse_Detected_When_Token_is_Revoked() + { + var store = new InMemoryRefreshTokenStore(); + var hasher = CreateHasher(); + var validator = CreateValidator(store); + + var now = DateTimeOffset.UtcNow; + + var rawToken = "refresh-token-1"; + var hash = hasher.Hash(rawToken); + + await store.StoreAsync(null, new StoredRefreshToken + { + TenantId = null, + TokenHash = hash, + UserKey = UserKey.FromString("user-1"), + SessionId = TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), + ChainId = SessionChainId.New(), + IssuedAt = now.AddMinutes(-5), + ExpiresAt = now.AddMinutes(5), + RevokedAt = now + }); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + TenantId = null, + RefreshToken = rawToken, + Now = now, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + }); + + Assert.False(result.IsValid); + Assert.True(result.IsReuseDetected); + } + + [Fact] + public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() + { + var store = new InMemoryRefreshTokenStore(); + var validator = CreateValidator(store); + + var now = DateTimeOffset.UtcNow; + + await store.StoreAsync(null, new StoredRefreshToken + { + TenantId = null, + TokenHash = "hash-2", + UserKey = UserKey.FromString("user-1"), + SessionId = TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), + ChainId = SessionChainId.New(), + IssuedAt = now, + ExpiresAt = now.AddMinutes(10) + }); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + TenantId = null, + RefreshToken = "hash-2", + ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), + Now = now, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + } +} + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs index 13011f53..605ff14e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -2,22 +2,66 @@ namespace CodeBeam.UltimateAuth.Tests.Unit; -public class UAuthSessionChainTests +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 Rotating_chain_increments_rotation_count() + public void New_chain_has_expected_initial_state() { - var chain = UAuthSessionChain.Create( - ChainId.New(), + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), tenantId: null, - userId: "user-1", + userKey: UserKey.FromString("user-1"), securityVersion: 0, ClaimsSnapshot.Empty); - var rotated = chain.RotateSession(new AuthSessionId("s2")); + 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(), + null, + UserKey.FromString("user-1"), + 0, + ClaimsSnapshot.Empty); + + var sessionId = CreateSessionId("s1"); + var rotated = chain.RotateSession(sessionId); Assert.Equal(1, rotated.RotationCount); - Assert.Equal("s2", rotated.ActiveSessionId?.Value); + 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(), + null, + UserKey.FromString("user-1"), + 0, + ClaimsSnapshot.Empty); + + var first = chain.RotateSession(CreateSessionId("s1")); + var second = first.RotateSession(CreateSessionId("s2")); + + Assert.Equal(2, second.RotationCount); + Assert.Equal(CreateSessionId("s2"), second.ActiveSessionId); } [Fact] @@ -25,16 +69,56 @@ public void Revoked_chain_does_not_rotate() { var now = DateTimeOffset.UtcNow; - var chain = UAuthSessionChain.Create( - ChainId.New(), + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), null, - "user-1", + UserKey.FromString("user-1"), 0, ClaimsSnapshot.Empty); var revoked = chain.Revoke(now); - var rotated = revoked.RotateSession(new AuthSessionId("s2")); + var rotated = revoked.RotateSession(CreateSessionId("s2")); 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(), + null, + UserKey.FromString("user-1"), + 0, + ClaimsSnapshot.Empty); + + 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(), + null, + UserKey.FromString("user-1"), + 0, + ClaimsSnapshot.Empty); + + 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 index 1bf3f358..7548b28d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -5,19 +5,23 @@ 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( - new AuthSessionId("s1"), + var session = UAuthSession.Create( + sessionId: sessionId, tenantId: null, - userId: "user-1", - chainId: ChainId.New(), + userKey: UserKey.FromString("user-1"), + chainId: SessionChainId.New(), now, now.AddMinutes(10), - DeviceInfo.Unknown, + DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -32,15 +36,16 @@ public void Revoke_marks_session_as_revoked() public void Revoking_twice_returns_same_instance() { var now = DateTimeOffset.UtcNow; + AuthSessionId.TryCreate(ValidRaw, out var sessionId); - var session = UAuthSession.Create( - new AuthSessionId("s1"), + var session = UAuthSession.Create( + sessionId, null, - "user-1", - ChainId.New(), + UserKey.FromString("user-1"), + SessionChainId.New(), now, now.AddMinutes(10), - DeviceInfo.Unknown, + DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs index 70b7269e..8b2dcf72 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs @@ -15,6 +15,26 @@ public FakeUAuthClient(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(PkceLoginRequest request) + { + throw new NotImplementedException(); + } + public Task GetCurrentPrincipalAsync() { throw new NotImplementedException(); @@ -30,6 +50,11 @@ public Task LogoutAsync() throw new NotImplementedException(); } + public Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string? returnUrl = null) + { + throw new NotImplementedException(); + } + public Task ReauthAsync() { throw new NotImplementedException(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs new file mode 100644 index 00000000..ee19e828 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System; +using System.Collections.Generic; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Unit +{ + internal static class TestIds + { + public static AuthSessionId Session(string raw) + { + if (!AuthSessionId.TryCreate(raw, out var id)) + throw new InvalidOperationException($"Invalid test AuthSessionId: {raw}"); + + return id; + } + } +} From cf2cf6f7d754c8ab7549e36968e6c1dcbf102bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:20:31 +0300 Subject: [PATCH 24/50] Preparation of First Release (Part 6/7) (#14) * Preparation of First Release (Part 6/7) * Add Required Projects * Project Name Change to Proper Ones * Add Contracts & Reference Projects For Users & Credentials * Complete Reference & InMemory Projects for Users & Credentials * Add Role Support & Enhance ClaimsSnapshot * User Management Endpoints & Orchestrator Basics * Add Credential Endpoints * Added Policy Project * Added User Client --- UltimateAuth.slnx | 13 +- ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 10 +- .../Components/Pages/Home.razor.cs | 8 +- .../Program.cs | 34 ++- ...am.UltimateAuth.Sample.BlazorServer.csproj | 10 + .../Components/Pages/Home.razor | 10 +- .../Components/Pages/Home.razor.cs | 45 +++- .../Program.cs | 36 ++- ...ateAuth.Sample.BlazorStandaloneWasm.csproj | 3 + .../Pages/Home.razor | 4 + .../Pages/Home.razor.cs | 14 +- .../Abstractions/IBrowserPostClient.cs | 28 --- .../DefaultUAuthStateManager.cs | 2 +- .../CodeBeam.UltimateAuth.Client.csproj | 2 + .../Contracts/BrowserPostJsonResult.cs | 10 - .../Contracts/BrowserPostResult.cs | 9 - ...stRawResult.cs => UAuthTransportResult.cs} | 2 +- ...teAuthClientServiceCollectionExtensions.cs | 5 +- .../BlazorServerSessionCoordinator.cs | 2 +- .../Infrastructure/BrowserPostClient.cs | 75 ------ .../Infrastructure/IUAuthRequestClient.cs | 15 ++ .../Infrastructure/UAuthRequestClient.cs | 79 ++++++ .../Infrastructure/UAuthResultMapper.cs | 51 ++++ .../Options/UAuthClientOptions.cs | 16 +- .../Services/DefaultFlowClient.cs | 221 +++++++++++++++++ .../Services/DefaultUserClient.cs | 70 ++++++ .../Services/IFlowClient.cs | 16 ++ .../Services/IUAuthClient.cs | 14 +- .../Services/IUserClient.cs | 19 ++ .../Services/UAuthClient.cs | 229 +----------------- .../wwwroot/uauth.js | 44 +++- .../Abstractions/Auth/IAuthContextFactory.cs | 8 + .../Authority/IAccessAuthority.cs | 10 + .../Authority/IAccessInvariant.cs | 9 + .../Abstractions/Authority/IAccessPolicy.cs | 10 + .../Abstractions/Authority/IAuthAuthority.cs | 3 +- .../Authority/IAuthorityInvariant.cs | 2 +- .../Authority/IAuthorityPolicy.cs | 2 +- .../Infrastructure/IUAuthPasswordHasher.cs | 2 +- .../Principals/IUserAuthenticator.cs | 9 - .../Principals/IUserClaimsProvider.cs | 8 + .../Abstractions/Services/ISessionService.cs | 12 + .../Abstractions/Services/IUAuthService.cs | 14 +- .../Services/IUAuthUserService.cs | 28 +-- .../Abstractions/Stores/IUAuthUserStore.cs | 6 +- .../Contracts/Authority/AccessContext.cs | 47 ++++ .../Contracts/Authority/AccessDecision.cs | 40 +++ ...ationResult.cs => AccessDecisionResult.cs} | 10 +- .../Authority/AuthenticationContext.cs | 14 -- .../Contracts/Common/DeleteMode.cs | 8 + .../Contracts/Common/UAuthResult.cs | 20 ++ .../Contracts/Login/LoginResult.cs | 7 +- .../Contracts/User/AuthUserSnapshot.cs | 10 + .../Contracts/User/UserContext.cs | 2 +- .../Domain/AuthFlowType.cs | 5 + .../Domain/ICurrentUser.cs | 9 + .../Principals/ClaimsSnapshotBuilder.cs | 39 +++ .../Domain/Session/ClaimsSnapshot.cs | 146 +++++++---- .../Domain/User/{IUser.cs => IAuthSubject.cs} | 2 +- .../Domain/User/UserKey.cs | 34 ++- .../Extensions/ClaimSnapshotExtensions.cs | 45 ---- .../Extensions/ClaimsSnapshotExtensions.cs | 61 +++++ .../Infrastructure/AuthUserRecord.cs | 50 ++++ .../Authority/DefaultAuthAuthority.cs | 13 +- .../Authority/DeviceMismatchPolicy.cs | 10 +- .../Authority/DevicePresenceInvariant.cs | 6 +- .../Authority/ExpiredSessionInvariant.cs | 10 +- .../InvalidOrRevokedSessionInvariant.cs | 10 +- .../Authority/UAuthModeOperationPolicy.cs | 20 +- .../Infrastructure/IInMemoryUserIdProvider.cs | 8 + .../Infrastructure/UAuthUserIdConverter.cs | 18 +- .../Infrastructure/UserKeyJsonConverter.cs | 22 ++ .../Infrastructure/UserRecord.cs | 16 -- .../Auth/Context/AuthFlowContextFactory.cs | 1 + .../Context/DefaultAccessContextFactory.cs | 53 ++++ .../Auth/Context/DefaultAuthContextFactory.cs | 24 ++ .../Auth/Context/DefaultAuthFlow.cs | 5 +- .../Auth/Context/IAccessContextFactory.cs | 9 + .../UAuthAuthenticationExtension.cs | 3 +- .../UAuthAuthenticationHandler.cs | 61 +++-- .../CodeBeam.UltimateAuth.Server.csproj | 11 +- .../Defaults/UAuthActions.cs | 49 ++++ .../UAuthCookieDefaults.cs | 2 +- .../IAuthorizationEndpointHandler.cs | 13 + .../ICredentialEndpointHandler.cs | 14 ++ .../IUserLifecycleEndpointHandler.cs | 11 + .../IUserProfileAdminEndpointHandler.cs | 11 + .../IUserProfileEndpointHandler.cs | 10 + .../Endpoints/DefaultLoginEndpointHandler.cs | 4 +- .../DefaultValidateEndpointHandler.cs | 1 + .../Endpoints/UAuthEndpointRegistrar.cs | 88 ++++++- .../Extensions/HttpContextJsonExtensions.cs | 26 ++ .../UAuthServerServiceCollectionExtensions.cs | 153 +++++++++--- .../DefaultCredentialResponseWriter.cs | 0 .../DefaultPrimaryCredentialResolver.cs | 0 .../DefaultAccessPolicyProvider.cs | 21 ++ .../DefaultUserAuthenticator.cs | 50 ---- .../Orchestrator/DefaultAccessAuthority.cs | 60 +++++ .../Orchestrator/IAccessCommand.cs | 14 ++ .../Orchestrator/IAccessOrchestrator.cs | 10 + .../Orchestrator/RevokeAllSessionsCommand.cs | 24 ++ .../Orchestrator/RevokeRootCommand.cs | 12 +- .../Orchestrator/UAuthAccessOrchestrator.cs | 51 ++++ .../IInnerSessionIdResolver.cs | 0 .../{ => SessionId}/ISessionIdResolver.cs | 0 .../User/HttpContextCurrentUser.cs | 25 ++ .../{ => User}/IUserAccessor.cs | 0 .../{ => User}/UAuthUserAccessor.cs | 0 .../Infrastructure/{ => User}/UAuthUserId.cs | 0 .../{ => User}/UserAccessorBridge.cs | 0 .../Login/DefaultLoginAuthority.cs | 34 +++ .../Login/DefaultLoginOrchestrator.cs | 166 +++++++++++++ .../Login/ILoginAuthority.cs | 17 ++ .../Login/ILoginOrchestrator.cs | 16 ++ .../Login/LoginDecision.cs | 26 ++ .../Login/LoginDecisionContext.cs | 50 ++++ .../Login/LoginDecisionKind.cs | 9 + .../Options/UAuthServerOptions.cs | 17 +- .../Options/UserIdentifierOptions.cs | 29 +++ .../Services/DefaultSessionService.cs | 35 +++ .../ISessionQueryService.cs | 2 +- .../Services/UAuthFlowService.cs | 117 +-------- .../UAuthSessionQueryService.cs | 14 +- .../Services/UAuthUserService.cs | 41 ---- .../IUAuthUserManagementService.cs | 36 --- .../Abstractions/IUAuthUserProfileService.cs | 23 -- .../Extensions/.gitkeep | 1 - .../Middlewares/.gitkeep | 1 - .../Options/.gitkeep | 1 - .../Services/.gitkeep | 1 - .../Users/Models/AdminUserFilter.cs | 10 - .../Users/Models/ChangePasswordRequest.cs | 13 - .../Users/Models/ConfigureMfaRequest.cs | 12 - .../Users/Models/ResetPasswordRequest.cs | 12 - .../Users/Models/UpdateProfileRequest.cs | 8 - .../Users/Models/UserDto.cs | 16 -- .../Users/Models/UserProfileDto.cs | 14 -- ...ltimateAuth.Authorization.Contracts.csproj | 17 ++ .../Dtos/PermissionDto.cs | 6 + .../Dtos/RoleDto.cs | 6 + .../Requests/AssignRoleRequest.cs | 7 + .../Requests/AuthorizationCheckRequest.cs | 9 + .../Requests/AuthorizationRequest.cs | 24 ++ .../Responses/AuthorizationResult.cs | 25 ++ .../Responses/UserRolesResponse.cs | 11 + ...UltimateAuth.Authorization.InMemory.csproj | 19 ++ .../AuthorizationInMemoryExtensions.cs | 16 ++ .../IAuthorizationSeeder.cs | 7 + .../InMemoryAuthorizationSeeder.cs | 23 ++ .../Stores/InMemoryUserRoleStore.cs | 50 ++++ ...ltimateAuth.Authorization.Reference.csproj | 19 ++ .../Commands/AssignUserRoleCommand.cs | 22 ++ .../Commands/GetUserRolesCommand.cs | 22 ++ .../Commands/RemoveUserRoleCommand.cs | 22 ++ .../Domain/Role.cs | 7 + .../DefaultAuthorizationEndpointHandler.cs | 111 +++++++++ .../AuthorizationReferenceExtensions.cs | 21 ++ .../DefaultRolePermissionResolver.cs | 35 +++ .../DefaultUserPermissionStore.cs | 24 ++ .../Services/DefaultAuthorizationService.cs | 37 +++ .../Services/DefaultUserRoleService.cs | 62 +++++ .../Services/IAuthorizationService.cs | 11 + .../Abstractions/IRolePermissionResolver.cs | 9 + .../Abstractions/IUserPermissionStore.cs | 9 + .../Abstractions/IUserRoleService.cs | 12 + .../Abstractions/IUserRoleStore.cs | 11 + ...CodeBeam.UltimateAuth.Authorization.csproj | 17 ++ .../DefaultAuthorizationClaimsProvider.cs | 35 +++ .../Domain/Permission.cs | 6 + .../PermissionAccessPolicy.cs | 29 +++ ....UltimateAuth.Credentials.Contracts.csproj | 16 ++ .../Dtos/CredentialDto.cs | 11 + .../Dtos/CredentialMetadata.cs | 6 + .../Dtos/CredentialSecurityState.cs | 45 ++++ .../Dtos/CredentialSecurityStatus.cs | 10 + .../Dtos/CredentialType.cs | 23 ++ .../Extensions/CredentialTypeParser.cs | 35 +++ .../Request/AddCredentialRequest.cs | 9 + .../Request/ChangeCredentialRequest.cs | 9 + .../Request/ResetPasswordRequest.cs | 15 ++ .../Request/RevokeAllCredentialsRequest.cs | 9 + .../Request/RevokeCredentialRequest.cs | 6 + .../Request/SetInitialCredentialRequest.cs | 15 ++ .../Request/ValidateCredentialsRequest.cs | 9 + .../Responses/AddCredentialResult.cs | 14 ++ .../Responses/ChangeCredentialResult.cs | 13 + .../Responses/CredentialActionResult.cs | 13 + .../Responses/CredentialChangeResult.cs | 6 + .../Responses/CredentialProvisionResult.cs | 41 ++++ .../Responses/CredentialValidationResult.cs | 33 +++ .../CredentialValidationResultDto.cs | 11 + .../Responses/GetCredentialsResult.cs | 5 + ...uth.Credentials.EntityFrameworkCore.csproj | 3 + .../EfCoreAuthUser.cs | 4 +- .../Infrastructure/EfCoreUserStore.cs | 166 ++++++------- .../ServiceCollectionExtensions.cs | 2 +- ...m.UltimateAuth.Credentials.InMemory.csproj | 3 + .../InMemoryCredentialStore.cs | 197 +++++++++++++++ .../InMemoryCredentialUser.cs | 43 ---- .../InMemoryCredentialsSeeder.cs | 24 -- .../InMemoryPasswordCredentialState.cs | 16 ++ .../InMemoryUserStore.cs | 123 ---------- .../ServiceCollectionExtensions.cs | 34 --- .../UltimateAuthDefaultsInMemoryExtensions.cs | 17 ++ .../AssemblyVisibility.cs | 3 + ....UltimateAuth.Credentials.Reference.csproj | 19 ++ .../Commands/ActivateCredentialCommand.cs | 22 ++ .../Commands/AddCredentialCommand.cs | 24 ++ .../Commands/ChangeCredentialCommand.cs | 22 ++ .../Commands/DeleteCredentialCommand.cs | 22 ++ .../Commands/GetAllCredentialsCommand.cs | 23 ++ .../Commands/RevokeCredentialCommand.cs | 22 ++ .../Commands/SetInitialCredentialCommand.cs | 22 ++ .../Domain/PasswordCredential.cs | 29 +++ .../DefaultCredentialEndpointHandler.cs | 161 ++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 19 ++ .../IUserCredentialsInternalService.cs | 10 + .../Services/DefaultUserCredentialsService.cs | 191 +++++++++++++++ .../Services/IUserCredentialsService.cs | 20 ++ .../Abstractions/ICredential.cs | 9 + .../Abstractions/ICredentialDescriptor.cs | 11 + .../Abstractions/ICredentialSecretStore.cs | 9 + .../Abstractions/ICredentialStore.cs | 17 ++ .../Abstractions/ICredentialValidator.cs | 8 + .../Abstractions/ILoginCredential.cs | 6 + .../Abstractions/IPublicKeyCredential.cs | 7 + .../Abstractions/ISecretCredential.cs | 6 + .../Abstractions/ISecurableCredential.cs | 9 + .../CodeBeam.UltimateAuth.Credentials.csproj | 17 ++ .../DefaultCredentialValidator.cs | 40 +++ .../Abstractions/IAccessPolicyProvider.cs | 9 + .../AssemblyVisibility.cs | 3 + .../CodeBeam.UltimateAuth.Policies.csproj | 22 ++ .../Defaults/CompiledAccessPolicySet.cs | 34 +++ .../Defaults/DefaultPolicySet.cs | 25 ++ .../Fluent/ConditionalPolicyBuilder.cs | 32 +++ .../Fluent/ConditionalScopeBuilder.cs | 46 ++++ .../Fluent/IConditionalPolicyBuilder.cs | 8 + .../Fluent/IPolicyBuilder.cs | 8 + .../Fluent/IPolicyScopeBuilder.cs | 11 + .../Fluent/PolicyBuilder.cs | 20 ++ .../Fluent/PolicyScopeBuilder.cs | 53 ++++ .../Policies/ConditionalAccessPolicy.cs | 23 ++ .../Policies/DenyCrossTenantPolicy.cs | 16 ++ .../Policies/RequireAdminPolicy.cs | 25 ++ .../Policies/RequireAuthenticatedPolicy.cs | 16 ++ .../Policies/RequireSelfOrAdminPolicy.cs | 27 +++ .../Policies/RequireSelfPolicy.cs | 19 ++ .../Registry/AccessPolicyRegistry.cs | 43 ++++ .../Registry/PolicyRule.cs | 18 ++ .../Argon2PasswordHasher.cs | 6 +- ...timateAuthServerBuilderArgon2Extensions.cs | 6 +- ...eBeam.UltimateAuth.Users.Contracts.csproj} | 4 + .../Dtos/MfaMethod.cs | 10 + .../Dtos/UserAccessDecision.cs | 6 + .../Dtos/UserIdentifierDto.cs | 12 + .../Dtos/UserIdentifierType.cs | 9 + .../Dtos/UserMfaStatusDto.cs | 15 ++ .../Dtos/UserProfileDto.cs | 25 ++ .../Dtos/UserProfileInput.cs | 10 + .../Dtos/UserStatus.cs | 29 +++ .../Requests/BeginMfaSetupRequest.cs | 7 + .../Requests/ChangeUserIdentifierRequest.cs | 9 + .../Requests/ChangeUserStatusRequest.cs | 10 + .../Requests/CompleteMfaSetupRequest.cs | 8 + .../Requests/CreateUserRequest.cs | 36 +++ .../Requests/DeleteUserIdentifierRequest.cs | 11 + .../Requests/DeleteUserRequest.cs | 11 + .../Requests/DisableMfaRequest.cs | 7 + .../Requests}/RegisterUserRequest.cs | 2 +- .../Requests/UpdateProfileRequest.cs | 11 + .../Requests/VerifyUserIdentifierRequest.cs | 8 + .../Responses/BeginMfaSetupResult.cs | 9 + .../Responses/GetUserIdentifiersResult.cs | 7 + .../Responses/IdentifierChangeResult.cs | 12 + .../Responses/IdentifierDeleteResult.cs | 11 + .../Responses/IdentifierVerificationResult.cs | 12 + .../Responses/UserCreateResult.cs | 31 +++ .../Responses/UserDeleteResult.cs | 42 ++++ .../Responses/UserStatusChangeResult.cs | 42 ++++ ...odeBeam.UltimateAuth.Users.InMemory.csproj | 18 ++ .../UltimateAuthUsersInMemoryExtensions.cs | 24 ++ .../Infrastructure/InMemoryUserIdProvider.cs | 15 ++ .../InMemoryUserSecurityStateProvider.cs | 13 + .../Stores/InMemoryUserIdentifierStore.cs | 133 ++++++++++ .../Stores/InMemoryUserLifecycleStore.cs | 150 ++++++++++++ .../Stores/InMemoryUserProfileStore.cs | 131 ++++++++++ .../Stores/InMemoryUserStore.cs | 86 +++++++ ...deBeam.UltimateAuth.Users.Reference.csproj | 19 ++ .../Commands/ChangeUserIdentifierCommand.cs | 15 ++ .../Commands/ChangeUserStatusCommand.cs | 18 ++ .../Commands/CreateUserCommand.cs | 18 ++ .../Commands/DeleteUserCommand.cs | 18 ++ .../Commands/DeleteUserIdentifierCommand.cs | 18 ++ .../Commands/GetCurrentUserProfileCommand.cs | 18 ++ .../Commands/GetUserIdentifiersCommand.cs | 19 ++ .../Commands/GetUserProfileAdminCommand.cs | 18 ++ .../UpdateCurrentUserProfileCommand.cs | 17 ++ .../Commands/UpdateUserProfileAdminCommand.cs | 17 ++ .../Commands/VerifyUserIdentifierCommand.cs | 18 ++ .../Domain/ReferenceUserProfile.cs | 24 ++ .../Domain/UserIdentifierRecord.cs | 21 ++ .../DefaultUserLifecycleEndpointHandler.cs | 96 ++++++++ .../DefaultUserProfileAdminEndpointHandler.cs | 61 +++++ .../DefaultUserProfileEndpointHandler.cs | 62 +++++ .../Extensions/ServiceCollectonExtensions.cs | 31 +++ .../Mapping/UserIdentifierMapper.cs | 18 ++ .../Mapping/UserProfileMapper.cs | 20 ++ .../Services/DefaultUserIdentifierService.cs | 126 ++++++++++ .../Services/DefaultUserLifecycleService.cs | 184 ++++++++++++++ .../DefaultUserProfileAdminService.cs | 54 +++++ .../Services/DefaultUserProfileService.cs | 60 +++++ .../Services/IUserIdentifierService.cs | 14 ++ .../Services/IUserLifecycleService.cs | 12 + .../Services/IUserProfileAdminService.cs | 12 + .../Services/IUserProfileService.cs | 11 + .../Stores/IUserIdentifierStore.cs | 16 ++ .../Stores/IUserLifecycleStore.cs | 14 ++ .../Stores/IUserProfileStore.cs | 15 ++ .../Abstractions/IUser.cs | 8 + .../Abstractions/IUserSecurityEvents.cs | 8 + .../Abstractions/IUserSecurityState.cs | 8 + .../IUserSecurityStateProvider.cs | 7 + .../Abstractions/IUserStore.cs | 25 ++ .../CodeBeam.UltimateAuth.Users.csproj | 15 ++ .../BlazorServerSessionCoordinatorTests.cs | 127 +++++----- .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 13 +- .../Core/UserIdConverterTests.cs | 102 ++++++++ .../{FakeUAuthClient.cs => FakeFlowClient.cs} | 5 +- 329 files changed, 7308 insertions(+), 1433 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostResult.cs rename src/CodeBeam.UltimateAuth.Client/Contracts/{BrowserPostRawResult.cs => UAuthTransportResult.cs} (85%) delete mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultFlowClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs rename src/CodeBeam.UltimateAuth.Core/Contracts/Authority/{AuthorizationResult.cs => AccessDecisionResult.cs} (68%) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/ICurrentUser.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs rename src/CodeBeam.UltimateAuth.Core/Domain/User/{IUser.cs => IAuthSubject.cs} (94%) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAccessContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/IAccessContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs rename src/CodeBeam.UltimateAuth.Server/{Authentication => Defaults}/UAuthCookieDefaults.cs (65%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => Credentials}/DefaultCredentialResponseWriter.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => Credentials}/DefaultPrimaryCredentialResolver.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultAccessPolicyProvider.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => SessionId}/IInnerSessionIdResolver.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => SessionId}/ISessionIdResolver.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => User}/IUserAccessor.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => User}/UAuthUserAccessor.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => User}/UAuthUserId.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => User}/UserAccessorBridge.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs rename src/CodeBeam.UltimateAuth.Server/{Infrastructure/Orchestrator => Services}/ISessionQueryService.cs (93%) rename src/CodeBeam.UltimateAuth.Server/{Infrastructure/Orchestrator => Services}/UAuthSessionQueryService.cs (86%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep delete mode 100644 src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep delete mode 100644 src/CodeBeam.UltimateAuth.Users/Options/.gitkeep delete mode 100644 src/CodeBeam.UltimateAuth.Users/Services/.gitkeep delete mode 100644 src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs delete mode 100644 src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/AuthorizationResult.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultUserRoleService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Abstractions/IAccessPolicyProvider.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IConditionalPolicyBuilder.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/ConditionalAccessPolicy.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyCrossTenantPolicy.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Registry/AccessPolicyRegistry.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Registry/PolicyRule.cs rename src/{CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj => users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj} (70%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs rename src/{CodeBeam.UltimateAuth.Users/Contracts => users/CodeBeam.UltimateAuth.Users.Contracts/Requests}/RegisterUserRequest.cs (93%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs rename tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/{FakeUAuthClient.cs => FakeFlowClient.cs} (92%) diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index ee6b5d95..f86cfc57 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -12,15 +12,26 @@ + + + + - + + + + + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index f0dc5ec9..fac1f58d 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -14,13 +14,21 @@ + + + + + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs index 7ae27acc..00bebb1b 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -78,19 +78,19 @@ private async Task ProgrammaticPkceLogin() var request = new PkceLoginRequest { - Identifier = "Admin", - Secret = "Password!", + Identifier = "admin", + Secret = "admin", AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty, CodeVerifier = credentials?.CodeVerifier ?? string.Empty, ReturnUrl = _state?.ReturnUrl ?? string.Empty }; - await UAuthClient.CompletePkceLoginAsync(request); + await UAuthClient.Flows.CompletePkceLoginAsync(request); } private async Task StartNewPkceAsync() { var returnUrl = await ResolveReturnUrlAsync(); - await UAuthClient.BeginPkceAsync(returnUrl); + await UAuthClient.Flows.BeginPkceAsync(returnUrl); } private async Task ResolveReturnUrlAsync() diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 67c40553..53d75b8d 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -1,16 +1,24 @@ +using CodeBeam.UltimateAuth.Authorization.InMemory; +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client.Extensions; -using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.Runtime; -using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; +using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Authentication; +using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.InMemory.Extensions; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Extensions; using MudBlazor.Services; using MudExtensions.Services; @@ -46,7 +54,12 @@ //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); }) - .AddInMemoryCredentials() + .AddUltimateAuthUsersInMemory() + .AddUltimateAuthUsersReference() + .AddUltimateAuthCredentialsInMemory() + .AddUltimateAuthCredentialsReference() + .AddUltimateAuthAuthorizationInMemory() + .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() .AddUltimateAuthArgon2(); @@ -73,6 +86,19 @@ var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + scope.ServiceProvider.GetRequiredService(); + scope.ServiceProvider.GetRequiredService(); + scope.ServiceProvider.GetRequiredService>(); + + var seeder = scope.ServiceProvider.GetService(); + //if (seeder is not null) + // await seeder.SeedAsync(); + + +} + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { 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 index 69effd56..0ebd5cf8 100644 --- 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 @@ -8,18 +8,28 @@ + + + + + + + + + + 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 index c36676ab..6919b0a8 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -18,7 +18,7 @@ @inject IClock Clock @inject IUAuthCookieManager CookieManager @inject IHttpContextAccessor HttpContextAccessor -@inject IUAuthClient UAuthClient +@inject IUAuthClient UAuth @inject NavigationManager Nav @inject IUAuthProductInfoProvider ProductInfo @inject AuthenticationStateProvider AuthStateProvider @@ -45,6 +45,8 @@ Programmatic Login + GetMe + Change User Inactive @@ -64,6 +66,12 @@ Not Authorized context is shown. + + + + This is Admin content. + + 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 index 806ffd99..80c99fe9 100644 --- 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 @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; @@ -41,17 +42,17 @@ private async Task ProgrammaticLogin() var deviceId = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { - Identifier = "Admin", - Secret = "Password!", + Identifier = "admin", + Secret = "admin", Device = DeviceContext.FromDeviceId(deviceId), }; - await UAuthClient.LoginAsync(request); + await UAuth.Flows.LoginAsync(request); _authState = await AuthStateProvider.GetAuthenticationStateAsync(); } private async Task ValidateAsync() { - var result = await UAuthClient.ValidateAsync(); + var result = await UAuth.Flows.ValidateAsync(); Snackbar.Add( result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", @@ -60,13 +61,45 @@ private async Task ValidateAsync() private async Task LogoutAsync() { - await UAuthClient.LogoutAsync(); + await UAuth.Flows.LogoutAsync(); Snackbar.Add("Logged out", Severity.Success); } private async Task RefreshAsync() { - await UAuthClient.RefreshAsync(); + await UAuth.Flows.RefreshAsync(); + } + + private async Task HandleGetMe() + { + var profileResult = await UAuth.Users.GetMeAsync(); + if (profileResult.Ok) + { + var profile = profileResult.Value; + Snackbar.Add($"User Profile: {profile?.UserName} ({profile?.DisplayName})", Severity.Info); + } + else + { + Snackbar.Add($"Failed to get profile: {profileResult.Error}", Severity.Error); + } + } + + private async Task ChangeUserInactive() + { + ChangeUserStatusRequest request = new ChangeUserStatusRequest + { + UserKey = UserKey.FromString("user"), + NewStatus = UserStatus.Disabled + }; + var result = await UAuth.Users.ChangeStatusAsync(request); + if (result.Ok) + { + Snackbar.Add($"User is disabled.", Severity.Info); + } + else + { + Snackbar.Add($"Failed to change user status.", Severity.Error); + } } protected override void OnAfterRender(bool firstRender) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index e449e4e4..cff25580 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,16 +1,25 @@ +using CodeBeam.UltimateAuth.Authorization.InMemory; +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; +using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Authentication; +using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users.InMemory.Extensions; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Extensions; using Microsoft.AspNetCore.Components; using MudBlazor.Services; using MudExtensions.Services; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -24,6 +33,8 @@ builder.Services.AddMudServices(); builder.Services.AddMudExtensions(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApi(); builder.Services .AddAuthentication(options => @@ -44,7 +55,12 @@ //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); }) - .AddInMemoryCredentials() + .AddUltimateAuthUsersInMemory() + .AddUltimateAuthUsersReference() + .AddUltimateAuthCredentialsInMemory() + .AddUltimateAuthCredentialsReference() + .AddUltimateAuthAuthorizationInMemory() + .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() .AddUltimateAuthArgon2(); @@ -80,6 +96,17 @@ var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService>(); + + var seeder = scope.ServiceProvider.GetService(); + //if (seeder is not null) + // await seeder.SeedAsync(); +} + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -87,6 +114,11 @@ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } +else +{ + app.MapOpenApi(); + app.MapScalarApiReference(); +} app.UseHttpsRedirection(); 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 index d1f38375..36d7dae1 100644 --- 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 @@ -17,8 +17,11 @@ + + + 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 index e2f5e680..da54578a 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -59,6 +59,10 @@ Not Authorized context is shown. + + + This is Admin content. + 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 index f4a6fef7..44c873a9 100644 --- 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 @@ -46,22 +46,22 @@ private async Task ProgrammaticLogin() var device = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { - Identifier = "Admin", - Secret = "Password!", + Identifier = "admin", + Secret = "admin", Device = DeviceContext.FromDeviceId(device), }; - await UAuthClient.LoginAsync(request); + await UAuthClient.Flows.LoginAsync(request); } private async Task StartPkceLogin() { - await UAuthClient.BeginPkceAsync(); + await UAuthClient.Flows.BeginPkceAsync(); //await UAuthClient.NavigateToHubLoginAsync(Nav.Uri); } private async Task ValidateAsync() { - var result = await UAuthClient.ValidateAsync(); + var result = await UAuthClient.Flows.ValidateAsync(); Snackbar.Add( result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", @@ -70,13 +70,13 @@ private async Task ValidateAsync() private async Task LogoutAsync() { - await UAuthClient.LogoutAsync(); + await UAuthClient.Flows.LogoutAsync(); Snackbar.Add("Logged out", Severity.Success); } private async Task RefreshAsync() { - await UAuthClient.RefreshAsync(); + await UAuthClient.Flows.RefreshAsync(); } private async Task RefreshAuthState() diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs deleted file mode 100644 index efded6e8..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserPostClient.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Contracts; - -namespace CodeBeam.UltimateAuth.Client.Abstractions -{ - public interface IBrowserPostClient - { - /// - /// Sends a POST request to the specified endpoint with the provided form data and navigates to the resulting. Submits a form. - /// location asynchronously. - /// - /// The relative or absolute URI of the endpoint to which the POST request is sent. Cannot be null or empty. - /// An optional collection of key-value pairs representing form data to include in the POST request. If null, no - /// form data is sent. - /// A task that represents the asynchronous navigation operation. - Task NavigatePostAsync(string endpoint, IDictionary? data = null); - - /// - /// Background POST request with JS fetch. - /// - /// - /// - Task FetchPostAsync(string endpoint, IDictionary? data = null); - - //Task> FetchPostJsonAsync(string url, IDictionary? data = null); - - Task FetchPostJsonRawAsync(string endpoint, IDictionary? data = null); - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs index fd9dbae4..1eb5b372 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs @@ -24,7 +24,7 @@ public async Task EnsureAsync(CancellationToken ct = default) return; await _bootstrapper.EnsureStartedAsync(); - var result = await _client.ValidateAsync(); + var result = await _client.Flows.ValidateAsync(); if (!result.IsValid) { diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index 3d97996d..5ead5c54 100644 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -30,6 +30,8 @@ + + diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs deleted file mode 100644 index 643423df..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostJsonResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Client.Contracts -{ - public sealed record BrowserPostJsonResult - { - public bool Ok { get; init; } - public int Status { get; init; } - public string? RefreshOutcome { get; init; } - public T? Body { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostResult.cs deleted file mode 100644 index 1b48e995..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostResult.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CodeBeam.UltimateAuth.Client.Contracts -{ - public sealed record BrowserPostResult - { - public bool Ok { get; init; } - public int Status { get; init; } - public string? RefreshOutcome { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs rename to src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs index 446b9823..867cfe8c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/BrowserPostRawResult.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Client.Contracts { - public sealed class BrowserPostRawResult + public sealed class UAuthTransportResult { public bool Ok { get; init; } public int Status { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs index 752739e9..fab6aae4 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Client.Utilities; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; @@ -96,8 +97,10 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol o.Refresh.Interval ??= TimeSpan.FromMinutes(5); }); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.TryAddScoped(); services.AddScoped(sp => { diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs index f7228640..0b0d06ed 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs @@ -48,7 +48,7 @@ private async Task RunAsync(CancellationToken ct) while (await _timer!.WaitForNextTickAsync(ct)) { _diagnostics.MarkAutomaticRefresh(); - var result = await _client.RefreshAsync(isAuto: true); + var result = await _client.Flows.RefreshAsync(isAuto: true); switch (result.Outcome) { diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs deleted file mode 100644 index 02bc7bc5..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserPostClient.cs +++ /dev/null @@ -1,75 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.Extensions.Options; -using Microsoft.JSInterop; - -namespace CodeBeam.UltimateAuth.Client.Infrastructure -{ - internal sealed class BrowserPostClient : IBrowserPostClient - { - private readonly IJSRuntime _js; - private UAuthOptions _coreOptions; - - public BrowserPostClient(IJSRuntime js, IOptions coreOptions) - { - _js = js; - _coreOptions = coreOptions.Value; - } - - public Task NavigatePostAsync(string endpoint, IDictionary? data = null) - { - return _js.InvokeVoidAsync("uauth.post", new - { - url = endpoint, - mode = "navigate", - data = data, - clientProfile = _coreOptions.ClientProfile.ToString() - }).AsTask(); - } - - public async Task FetchPostAsync(string endpoint, IDictionary? data = null) - { - var result = await _js.InvokeAsync("uauth.post", new - { - url = endpoint, - mode = "fetch", - expectJson = false, - data = data, - clientProfile = _coreOptions.ClientProfile.ToString() - }); - - return result; - } - - public async Task FetchPostJsonRawAsync(string endpoint, IDictionary? data = null) - { - var postData = data ?? new Dictionary(); - return await _js.InvokeAsync("uauth.post", - new - { - url = endpoint, - mode = "fetch", - expectJson = true, - data = postData, - clientProfile = _coreOptions.ClientProfile.ToString() - }); - } - - - //public async Task> FetchPostJsonAsync(string endpoint, IDictionary? data = null) - //{ - // var result = await _js.InvokeAsync>("uauth.post", new - // { - // url = endpoint, - // mode = "fetch", - // expectJson = true, - // data = data, - // clientProfile = _coreOptions.ClientProfile.ToString() - // }); - - // return result; - //} - - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs new file mode 100644 index 00000000..f93dd98d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Client.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 SendFormForJsonAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + + Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs new file mode 100644 index 00000000..beb15f80 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs @@ -0,0 +1,79 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; + +// TODO: Add fluent helper API like RequiredOk +namespace CodeBeam.UltimateAuth.Client.Infrastructure +{ + internal sealed class UAuthRequestClient : IUAuthRequestClient + { + private readonly IJSRuntime _js; + private UAuthOptions _coreOptions; + + public UAuthRequestClient(IJSRuntime js, IOptions coreOptions) + { + _js = js; + _coreOptions = coreOptions.Value; + } + + public Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return _js.InvokeVoidAsync("uauth.post", ct, new + { + url = endpoint, + mode = "navigate", + data = form, + clientProfile = _coreOptions.ClientProfile.ToString() + }).AsTask(); + } + + public async Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = await _js.InvokeAsync("uauth.post", ct, new + { + url = endpoint, + mode = "fetch", + expectJson = false, + data = form, + clientProfile = _coreOptions.ClientProfile.ToString() + }); + + return result; + } + + public async Task SendFormForJsonAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var postData = form ?? new Dictionary(); + return await _js.InvokeAsync("uauth.post", ct, + new + { + url = endpoint, + mode = "fetch", + expectJson = true, + data = postData, + clientProfile = _coreOptions.ClientProfile.ToString() + }); + } + + public async Task SendJsonAsync(string endpoint, object? payload = default, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _js.InvokeAsync("uauth.postJson", ct, new + { + url = endpoint, + payload = payload, + clientProfile = _coreOptions.ClientProfile.ToString() + }); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs new file mode 100644 index 00000000..245575ee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure +{ + internal static class UAuthResultMapper + { + public static UAuthResult FromJson(UAuthTransportResult raw) + { + if (!raw.Ok) + { + return new UAuthResult + { + Ok = false, + Status = raw.Status + }; + } + + if (raw.Body is null) + { + return new UAuthResult + { + Ok = true, + Status = raw.Status, + Value = default + }; + } + + var value = raw.Body.Value.Deserialize( + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return new UAuthResult + { + Ok = true, + Status = raw.Status, + Value = value + }; + } + + public static UAuthResult FromStatus(UAuthTransportResult raw) + => new() + { + Ok = raw.Ok, + Status = raw.Status + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index 97a8aeed..94f0af80 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -15,15 +15,15 @@ public sealed class AuthEndpointOptions /// /// Base URL of UAuthHub (e.g. https://localhost:6110) /// - public string Authority { get; set; } = string.Empty; + public string Authority { get; set; } = "/auth"; - public string Login { get; set; } = "/auth/login"; - public string Logout { get; set; } = "/auth/logout"; - public string Refresh { get; set; } = "/auth/refresh"; - public string Reauth { get; set; } = "/auth/reauth"; - public string Validate { get; set; } = "/auth/validate"; - public string PkceAuthorize { get; set; } = "/auth/pkce/authorize"; - public string PkceComplete { get; set; } = "/auth/pkce/complete"; + public string Login { get; set; } = "/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 PkceComplete { get; set; } = "/pkce/complete"; public string HubLoginPath { get; set; } = "/uauthhub/login"; } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultFlowClient.cs new file mode 100644 index 00000000..cda6ed49 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultFlowClient.cs @@ -0,0 +1,221 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + internal class DefaultFlowClient : IFlowClient + { + private readonly IUAuthRequestClient _post; + private readonly UAuthClientOptions _options; + private readonly UAuthOptions _coreOptions; + private readonly UAuthClientDiagnostics _diagnostics; + private readonly NavigationManager _nav; + + public DefaultFlowClient( + IUAuthRequestClient post, + IOptions options, + IOptions coreOptions, + UAuthClientDiagnostics diagnostics, + NavigationManager nav) + { + _post = post; + _options = options.Value; + _coreOptions = coreOptions.Value; + _diagnostics = diagnostics; + _nav = nav; + } + + public async Task LoginAsync(LoginRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Login); + await _post.NavigateAsync(url, request.ToDictionary()); + } + + public async Task LogoutAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Logout); + await _post.NavigateAsync(url); + } + + public async Task RefreshAsync(bool isAuto = false) + { + if (isAuto == false) + { + _diagnostics.MarkManualRefresh(); + } + + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Refresh); + var result = await _post.SendFormAsync(url); + var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); + switch (refreshOutcome) + { + case RefreshOutcome.NoOp: + _diagnostics.MarkRefreshNoOp(); + break; + case RefreshOutcome.Touched: + _diagnostics.MarkRefreshTouched(); + break; + case RefreshOutcome.ReauthRequired: + _diagnostics.MarkRefreshReauthRequired(); + break; + case RefreshOutcome.None: + _diagnostics.MarkRefreshUnknown(); + break; + } + + return new RefreshResult + { + Ok = result.Ok, + Status = result.Status, + Outcome = refreshOutcome + }; + } + + public async Task ReauthAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Reauth); + await _post.NavigateAsync(_options.Endpoints.Reauth); + } + + public async Task ValidateAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate); + var raw = await _post.SendFormForJsonAsync(url); + + if (!raw.Ok || raw.Body is null) + { + return new AuthValidationResult + { + IsValid = false, + State = "transport" + }; + } + + var body = raw.Body.Value.Deserialize( + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return body ?? new AuthValidationResult + { + IsValid = false, + State = "deserialize" + }; + } + + public async Task BeginPkceAsync(string? returnUrl = null) + { + var pkce = _options.Login.Pkce; + + if (!pkce.Enabled) + throw new InvalidOperationException("PKCE login is disabled by configuration."); + + var verifier = CreateVerifier(); + var challenge = CreateChallenge(verifier); + + var authorizeUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceAuthorize); + + var raw = await _post.SendFormForJsonAsync( + 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.DefaultReturnUrl + ?? _nav.Uri; + + if (pkce.AutoRedirect) + { + await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl); + } + } + + public async Task CompletePkceLoginAsync(PkceLoginRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _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 + }; + + await _post.NavigateAsync(url, payload); + } + + private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) + { + var hubLoginUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.HubLoginPath); + + var data = new Dictionary + { + ["authorization_code"] = authorizationCode, + ["code_verifier"] = codeVerifier, + ["return_url"] = returnUrl, + ["client_profile"] = _coreOptions.ClientProfile.ToString() + }; + + return _post.NavigateAsync(hubLoginUrl, data); + } + + + // ---------------- PKCE CRYPTO ---------------- + + private static string CreateVerifier() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Base64UrlEncode(bytes); + } + + private static string CreateChallenge(string verifier) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); + return Base64UrlEncode(hash); + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs new file mode 100644 index 00000000..62818e86 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs @@ -0,0 +1,70 @@ +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 DefaultUserClient : IUserClient + { + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public DefaultUserClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + public async Task> GetMeAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task UpdateMeAsync(UpdateProfileRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task> CreateAsync(CreateUserRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/create"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeStatusAsync(ChangeUserStatusRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/status"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> DeleteAsync(DeleteUserRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/delete"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetProfileAsync(UserKey userKey) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs new file mode 100644 index 00000000..c99d1456 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IFlowClient +{ + Task LoginAsync(LoginRequest request); + Task LogoutAsync(); + Task RefreshAsync(bool isAuto = false); + Task ReauthAsync(); + Task ValidateAsync(); + + Task BeginPkceAsync(string? returnUrl = null); + Task CompletePkceLoginAsync(PkceLoginRequest request); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs index 5bdcfe56..e6bddb43 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -1,18 +1,10 @@ -using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Client.Services; namespace CodeBeam.UltimateAuth.Client { public interface IUAuthClient { - Task LoginAsync(LoginRequest request); - Task LogoutAsync(); - Task RefreshAsync(bool isAuto = false); - Task ReauthAsync(); - - Task ValidateAsync(); - - Task BeginPkceAsync(string? returnUrl = null); - Task CompletePkceLoginAsync(PkceLoginRequest request); + IFlowClient Flows { get; } + IUserClient Users { get; } } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs new file mode 100644 index 00000000..79d6bd90 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + public interface IUserClient + { + Task> CreateAsync(CreateUserRequest request); + Task> ChangeStatusAsync(ChangeUserStatusRequest request); + Task> DeleteAsync(DeleteUserRequest request); + + Task> GetMeAsync(); + Task UpdateMeAsync(UpdateProfileRequest request); + + Task> GetProfileAsync(UserKey userKey); + Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index 1cf53ebd..021de20c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -1,224 +1,15 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using CodeBeam.UltimateAuth.Client.Authentication; -using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Client.Diagnostics; -using CodeBeam.UltimateAuth.Client.Extensions; -using CodeBeam.UltimateAuth.Client.Infrastructure; -using CodeBeam.UltimateAuth.Client.Options; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Options; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; +using CodeBeam.UltimateAuth.Client.Services; -namespace CodeBeam.UltimateAuth.Client -{ - internal sealed class UAuthClient : IUAuthClient - { - private readonly IBrowserPostClient _post; - private readonly UAuthClientOptions _options; - private readonly UAuthOptions _coreOptions; - private readonly UAuthClientDiagnostics _diagnostics; - private readonly NavigationManager _nav; - - public UAuthClient( - IBrowserPostClient post, - IOptions options, - IOptions coreOptions, - UAuthClientDiagnostics diagnostics, - NavigationManager nav) - { - _post = post; - _options = options.Value; - _coreOptions = coreOptions.Value; - _diagnostics = diagnostics; - _nav = nav; - } - - public async Task LoginAsync(LoginRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Login); - await _post.NavigatePostAsync(url, request.ToDictionary()); - } - - public async Task LogoutAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Logout); - await _post.NavigatePostAsync(url); - } - - public async Task RefreshAsync(bool isAuto = false) - { - if (isAuto == false) - { - _diagnostics.MarkManualRefresh(); - } - - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Refresh); - var result = await _post.FetchPostAsync(url); - var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); - switch (refreshOutcome) - { - case RefreshOutcome.NoOp: - _diagnostics.MarkRefreshNoOp(); - break; - case RefreshOutcome.Touched: - _diagnostics.MarkRefreshTouched(); - break; - case RefreshOutcome.ReauthRequired: - _diagnostics.MarkRefreshReauthRequired(); - break; - case RefreshOutcome.None: - _diagnostics.MarkRefreshUnknown(); - break; - } - - return new RefreshResult - { - Ok = result.Ok, - Status = result.Status, - Outcome = refreshOutcome - }; - } - - public async Task ReauthAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Reauth); - await _post.NavigatePostAsync(_options.Endpoints.Reauth); - } - - public async Task ValidateAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate); - var raw = await _post.FetchPostJsonRawAsync(url); - - if (!raw.Ok || raw.Body is null) - { - return new AuthValidationResult - { - IsValid = false, - State = "transport" - }; - } - - var body = raw.Body.Value.Deserialize( - new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - return body ?? new AuthValidationResult - { - IsValid = false, - State = "deserialize" - }; - } - - public async Task BeginPkceAsync(string? returnUrl = null) - { - var pkce = _options.Login.Pkce; - - if (!pkce.Enabled) - throw new InvalidOperationException("PKCE login is disabled by configuration."); - - var verifier = CreateVerifier(); - var challenge = CreateChallenge(verifier); - - var authorizeUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceAuthorize); - - var raw = await _post.FetchPostJsonRawAsync( - authorizeUrl, - new Dictionary - { - ["code_challenge"] = challenge, - ["challenge_method"] = "S256" - }); - - if (!raw.Ok || raw.Body is null) - throw new InvalidOperationException("PKCE authorize failed."); +namespace CodeBeam.UltimateAuth.Client; - 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.DefaultReturnUrl - ?? _nav.Uri; - - if (pkce.AutoRedirect) - { - await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl); - } - } - - public async Task CompletePkceLoginAsync(PkceLoginRequest request) - { - if (request is null) - throw new ArgumentNullException(nameof(request)); - - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _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 - }; - - await _post.NavigatePostAsync(url, payload); - } - - private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) - { - var hubLoginUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.HubLoginPath); - - var data = new Dictionary - { - ["authorization_code"] = authorizationCode, - ["code_verifier"] = codeVerifier, - ["return_url"] = returnUrl, - ["client_profile"] = _coreOptions.ClientProfile.ToString() - }; - - return _post.NavigatePostAsync(hubLoginUrl, data); - } - - - // ---------------- PKCE CRYPTO ---------------- - - private static string CreateVerifier() - { - var bytes = RandomNumberGenerator.GetBytes(32); - return Base64UrlEncode(bytes); - } - - private static string CreateChallenge(string verifier) - { - using var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); - return Base64UrlEncode(hash); - } - - private static string Base64UrlEncode(byte[] input) - { - return Convert.ToBase64String(input) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } +internal sealed class UAuthClient : IUAuthClient +{ + public IFlowClient Flows { get; } + public IUserClient Users { get; } + public UAuthClient(IFlowClient flows, IUserClient users) + { + Flows = flows; + Users = users; } } diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js index 94c26bc6..0aa48f70 100644 --- a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js +++ b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js @@ -100,7 +100,8 @@ window.uauth.post = async function (options) { } const headers = { "X-UDID": window.uauth.deviceId, - "X-UAuth-ClientProfile": clientProfile + "X-UAuth-ClientProfile": clientProfile, + "X-Requested-With": "UAuth" }; if (data) { @@ -136,6 +137,47 @@ window.uauth.post = async function (options) { }; }; +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; }; 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/Authority/IAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs new file mode 100644 index 00000000..bf61d883 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs @@ -0,0 +1,10 @@ +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..806d6c91 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs @@ -0,0 +1,9 @@ +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..487072fe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs @@ -0,0 +1,10 @@ +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 index 9da4a8fc..9a294587 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs @@ -4,7 +4,6 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions { public interface IAuthAuthority { - AuthorizationResult Decide(AuthContext context); + 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 index 32ce7dce..dc0cc0a5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs @@ -4,6 +4,6 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions { public interface IAuthorityInvariant { - AuthorizationResult Decide(AuthContext context); + AccessDecisionResult Decide(AuthContext context); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs index 235ea3d5..2b2021a2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs @@ -5,6 +5,6 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions public interface IAuthorityPolicy { bool AppliesTo(AuthContext context); - AuthorizationResult Decide(AuthContext context); + AccessDecisionResult Decide(AuthContext context); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs index 53246c1b..d6596c91 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs @@ -8,6 +8,6 @@ public interface IUAuthPasswordHasher { string Hash(string password); - bool Verify(string password, string hash); + bool Verify(string hash, string secret); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs deleted file mode 100644 index 21ee5ad3..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface IUserAuthenticator - { - Task> AuthenticateAsync(string? tenantId, AuthenticationContext context, 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..3be15c91 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core; + +public interface IUserClaimsProvider +{ + Task GetClaimsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs new file mode 100644 index 00000000..9dc9df9f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface ISessionService + { + Task RevokeAllAsync(AuthContext authContext, UserKey userKey, CancellationToken ct = default); + Task RevokeAllExceptChainAsync(AuthContext authContext, UserKey userKey, SessionChainId exceptChainId, CancellationToken ct = default); + Task RevokeRootAsync(AuthContext authContext, UserKey userKey, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs index c2c34239..9486398d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs @@ -5,11 +5,11 @@ /// Provides access to authentication flows, /// session lifecycle and user operations. /// - public interface IUAuthService - { - //IUAuthFlowService Flow { get; } - IUAuthSessionManager Sessions { get; } - //IUAuthTokenService Tokens { get; } - IUAuthUserService Users { get; } - } + //public interface IUAuthService + //{ + // //IUAuthFlowService Flow { get; } + // IUAuthSessionManager Sessions { get; } + // //IUAuthTokenService Tokens { get; } + // IUAuthUserService Users { get; } + //} } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs index af6e3bac..d546e55f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs @@ -1,15 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Defines the minimal user authentication contract expected by UltimateAuth. - /// This service does not manage sessions, tokens, or transport concerns. - /// For user management, CodeBeam.UltimateAuth.Users package is recommended. - /// - public interface IUAuthUserService - { - Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); - Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); - } -} +//namespace CodeBeam.UltimateAuth.Core.Abstractions +//{ +// /// +// /// Defines the minimal user authentication contract expected by UltimateAuth. +// /// This service does not manage sessions, tokens, or transport concerns. +// /// For user management, CodeBeam.UltimateAuth.Users package is recommended. +// /// +// public interface IUAuthUserService +// { +// Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); +// Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs index 96f4f54d..b97c47e1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs @@ -10,16 +10,16 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// public interface IUAuthUserStore { - Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken token = default); + Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken token = default); - Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default); + Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default); /// /// Retrieves a user by a login credential such as username or email. /// Returns null if no matching user exists. /// /// The user instance or null if not found. - Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken token = default); + Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken token = default); /// /// Returns the password hash for the specified user, if the user participates 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..dd008ec9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Collections; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class AccessContext + { + // Actor + public UserKey? ActorUserKey { get; init; } + public string? ActorTenantId { get; init; } + public bool IsAuthenticated { get; init; } + public bool IsSystemActor { get; init; } + + // Target + public string? Resource { get; init; } + public string? ResourceId { get; init; } + public string? ResourceTenantId { get; init; } + + public string Action { get; init; } = default!; + public IReadOnlyDictionary Attributes { get; init; } = EmptyAttributes.Instance; + + public bool IsCrossTenant => ActorTenantId != null && ResourceTenantId != null && !string.Equals(ActorTenantId, ResourceTenantId, StringComparison.Ordinal); + public bool IsSelfAction => ActorUserKey != null && ResourceId != null && string.Equals(ActorUserKey.Value, ResourceId, StringComparison.Ordinal); + public bool HasActor => ActorUserKey != null; + public bool HasTarget => ResourceId != null; + } + + 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..2320615a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs @@ -0,0 +1,40 @@ +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/AuthorizationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs similarity index 68% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs index 09af255c..e157c940 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs @@ -1,23 +1,23 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { - public sealed class AuthorizationResult + public sealed class AccessDecisionResult { public AuthorizationDecision Decision { get; } public string? Reason { get; } - private AuthorizationResult(AuthorizationDecision decision, string? reason) + private AccessDecisionResult(AuthorizationDecision decision, string? reason) { Decision = decision; Reason = reason; } - public static AuthorizationResult Allow() + public static AccessDecisionResult Allow() => new(AuthorizationDecision.Allow, null); - public static AuthorizationResult Deny(string reason) + public static AccessDecisionResult Deny(string reason) => new(AuthorizationDecision.Deny, reason); - public static AuthorizationResult Challenge(string reason) + public static AccessDecisionResult Challenge(string reason) => new(AuthorizationDecision.Challenge, reason); // Developer happiness helpers diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs deleted file mode 100644 index 53027dae..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record AuthenticationContext - { - public string? TenantId { get; init; } - public string Identifier { get; init; } = default!; - public string Secret { get; init; } = default!; - public AuthOperation Operation { get; init; } // Login, Reauth, Validate - public DeviceContext? Device { get; init; } - public string CredentialType { get; init; } = "password"; - } -} 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..e28fa7b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum DeleteMode + { + Soft, + Hard + } +} 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..2437c850 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -0,0 +1,20 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public class UAuthResult + { + public bool Ok { get; init; } + public int Status { get; init; } + + public string? Error { get; init; } + public string? ErrorCode { get; init; } + + public bool IsUnauthorized => Status == 401; + public bool IsForbidden => Status == 403; + } + + public sealed class UAuthResult : UAuthResult + { + public T? Value { get; init; } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs index f9be49fb..8324739e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -18,7 +18,12 @@ public sealed record LoginResult public bool RequiresMfa => Continuation?.Type == LoginContinuationType.Mfa; public bool RequiresPkce => Continuation?.Type == LoginContinuationType.Pkce; - public static LoginResult Failed() => new() { Status = LoginStatus.Failed }; + public static LoginResult Failed(AuthFailureReason? reason = null) + => new() + { + Status = LoginStatus.Failed, + FailureReason = reason + }; public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null) => new() diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs index 62b8fd10..ef8cdcce 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs @@ -1,6 +1,16 @@ 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; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs index 0c87265d..2063d0c1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts public sealed class UserContext { public TUserId? UserId { get; init; } - public IUser? User { get; init; } + public IAuthSubject? User { get; init; } public bool IsAuthenticated => UserId is not null; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs index ed0ca749..e18593a3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs @@ -20,6 +20,11 @@ public enum AuthFlowType UserInfo, PermissionQuery, + UserManagement, + UserProfile, + CredentialManagement, + AuthorizationManagement, + ApiAccess } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/ICurrentUser.cs b/src/CodeBeam.UltimateAuth.Core/Domain/ICurrentUser.cs new file mode 100644 index 00000000..399cad5a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/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/Principals/ClaimsSnapshotBuilder.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs new file mode 100644 index 00000000..6ceca575 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs @@ -0,0 +1,39 @@ +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/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index 21904618..c6f5f7bd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -5,24 +5,60 @@ namespace CodeBeam.UltimateAuth.Core.Domain { public sealed class ClaimsSnapshot { - public IReadOnlyDictionary Claims { get; } + private readonly IReadOnlyDictionary> _claims; + public IReadOnlyDictionary> Claims => _claims; [JsonConstructor] - public ClaimsSnapshot(IReadOnlyDictionary claims) + public ClaimsSnapshot(IReadOnlyDictionary> claims) { - Claims = new Dictionary(claims); + _claims = claims; } - public IReadOnlyDictionary AsDictionary() => Claims; + public static ClaimsSnapshot Empty { get; } = new(new Dictionary>()); - public bool TryGet(string type, out string value) => Claims.TryGetValue(type, out value); + 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); - public string? Get(string type) - => Claims.TryGetValue(type, out var value) - ? value - : null; + foreach (var (type, values) in Claims) + { + var first = values.FirstOrDefault(); + if (first is not null) + dict[type] = first; + } - public static ClaimsSnapshot Empty { get; } = new ClaimsSnapshot(new Dictionary()); + return dict; + } public override bool Equals(object? obj) { @@ -32,12 +68,15 @@ public override bool Equals(object? obj) if (Claims.Count != other.Claims.Count) return false; - foreach (var kv in Claims) + foreach (var (type, values) in Claims) { - if (!other.Claims.TryGetValue(kv.Key, out var v)) + if (!other.Claims.TryGetValue(type, out var otherValues)) + return false; + + if (values.Count != otherValues.Count) return false; - if (!string.Equals(kv.Value, v, StringComparison.Ordinal)) + if (!values.All(v => otherValues.Contains(v))) return false; } @@ -49,22 +88,37 @@ public override int GetHashCode() unchecked { int hash = 17; - foreach (var kv in Claims.OrderBy(x => x.Key)) + + foreach (var (type, values) in Claims.OrderBy(x => x.Key)) { - hash = hash * 23 + kv.Key.GetHashCode(); - hash = hash * 23 + kv.Value.GetHashCode(); + 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); + var dict = new Dictionary>(StringComparer.Ordinal); + foreach (var (type, value) in claims) - dict[type] = value; + { + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } - return new ClaimsSnapshot(dict); + 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) @@ -72,14 +126,20 @@ public ClaimsSnapshot With(params (string Type, string Value)[] claims) if (claims.Length == 0) return this; - var dict = new Dictionary(Claims, StringComparer.Ordinal); + var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); foreach (var (type, value) in claims) { - dict[type] = value; + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } + + set.Add(value); } - return new ClaimsSnapshot(dict); + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); } public ClaimsSnapshot Merge(ClaimsSnapshot other) @@ -90,44 +150,24 @@ public ClaimsSnapshot Merge(ClaimsSnapshot other) if (Claims.Count == 0) return other; - var dict = new Dictionary(Claims, StringComparer.Ordinal); + var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); - foreach (var kv in other.Claims) + foreach (var (type, values) in other.Claims) { - dict[kv.Key] = kv.Value; - } - - return new ClaimsSnapshot(dict); - } - - public static ClaimsSnapshot FromClaimsPrincipal(ClaimsPrincipal principal) - { - if (principal is null) - return Empty; - - if (principal.Identity?.IsAuthenticated != true) - return Empty; - - var dict = new Dictionary(StringComparer.Ordinal); + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } - foreach (var claim in principal.Claims) - { - dict[claim.Type] = claim.Value; + foreach (var value in values) + set.Add(value); } - return new ClaimsSnapshot(dict); + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); } - public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = "UltimateAuth") - { - if (Claims.Count == 0) - return new ClaimsPrincipal(new ClaimsIdentity()); - - var claims = Claims.Select(kv => new Claim(kv.Key, kv.Value)); - var identity = new ClaimsIdentity(claims, authenticationType); - - return new ClaimsPrincipal(identity); - } + public static ClaimsSnapshotBuilder Create() => new ClaimsSnapshotBuilder(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs index 5222ca98..97eec361 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs @@ -5,7 +5,7 @@ /// Includes the unique user identifier and an optional set of claims that /// may be used during authentication or session creation. /// - public interface IUser + public interface IAuthSubject { /// /// Gets the unique identifier of the user. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs index f696d21b..3e42de9d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs @@ -1,6 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain { - public readonly record struct UserKey + [JsonConverter(typeof(UserKeyJsonConverter))] + public readonly record struct UserKey : IParsable { public string Value { get; } @@ -31,6 +35,32 @@ public static UserKey FromString(string value) /// 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/Extensions/ClaimSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs deleted file mode 100644 index 2a8688d1..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Security.Claims; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Extensions -{ - public static class ClaimsSnapshotExtensions - { - public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = "UltimateAuth") - { - var claims = snapshot - .AsDictionary() - .Select(kv => new Claim(kv.Key, kv.Value)); - - var identity = new ClaimsIdentity(claims, authenticationType); - return new ClaimsPrincipal(identity); - } - - public static IReadOnlyCollection ToClaims(this ClaimsSnapshot snapshot) - { - if (snapshot == null) - return Array.Empty(); - - return snapshot - .AsDictionary() - .Select(kv => new Claim(kv.Key, kv.Value)) - .ToArray(); - } - - public static ClaimsSnapshot ToSnapshot(this IEnumerable claims) - { - if (claims == null) - return ClaimsSnapshot.Empty; - - return new ClaimsSnapshot( - claims - .GroupBy(c => c.Type) - .ToDictionary( - g => g.Key, - g => g.Last().Value, - StringComparer.Ordinal - ) - ); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs new file mode 100644 index 00000000..fa7bc2f2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -0,0 +1,61 @@ +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 = "UltimateAuth") + { + 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); + } + + /// + /// 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/Infrastructure/AuthUserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs new file mode 100644 index 00000000..79885c21 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs @@ -0,0 +1,50 @@ +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + /// + /// Represents the minimal, immutable user snapshot required by the UltimateAuth Core + /// during authentication discovery and subject binding. + /// + /// This type is NOT a domain user model. + /// It contains only normalized, opinionless fields that determine whether + /// a user can participate in authentication flows. + /// + /// AuthUserRecord is produced by the Users domain as a boundary projection + /// and is never mutated by the Core. + /// + public sealed record AuthUserRecord + { + /// + /// Application-level user identifier. + /// + public required TUserId Id { get; init; } + + /// + /// Primary login identifier (username, email, etc). + /// Used only for discovery and uniqueness checks. + /// + public required string Identifier { get; init; } + + /// + /// Indicates whether the user is considered active for authentication purposes. + /// Domain-specific statuses are normalized into this flag by the Users domain. + /// + public required bool IsActive { get; init; } + + /// + /// Indicates whether the user is deleted. + /// Deleted users are never eligible for authentication. + /// + public required bool IsDeleted { get; init; } + + /// + /// The timestamp when the user was originally created. + /// Provided for invariant validation and auditing purposes. + /// + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// The timestamp when the user was deleted, if applicable. + /// + public DateTimeOffset? DeletedAt { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs index 830ebfe4..1b826920 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs @@ -14,9 +14,8 @@ public DefaultAuthAuthority(IEnumerable invariants, IEnumer _policies = policies ?? Array.Empty(); } - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null) { - // 1. Invariants foreach (var invariant in _invariants) { var result = invariant.Decide(context); @@ -24,10 +23,11 @@ public AuthorizationResult Decide(AuthContext context) return result; } - // 2. Policies bool challenged = false; - foreach (var policy in _policies) + var effectivePolicies = _policies.Concat(policies ?? Enumerable.Empty()); + + foreach (var policy in effectivePolicies) { if (!policy.AppliesTo(context)) continue; @@ -42,8 +42,9 @@ public AuthorizationResult Decide(AuthContext context) } return challenged - ? AuthorizationResult.Challenge("Additional verification required.") - : AuthorizationResult.Allow(); + ? AccessDecisionResult.Challenge("Additional verification required.") + : AccessDecisionResult.Allow(); } + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs index d8472f83..1d53f385 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs @@ -8,7 +8,7 @@ public sealed class DeviceMismatchPolicy : IAuthorityPolicy public bool AppliesTo(AuthContext context) => context.Device is not null; - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { var device = context.Device; @@ -18,14 +18,14 @@ public AuthorizationResult Decide(AuthContext context) return context.Operation switch { AuthOperation.Access => - AuthorizationResult.Deny("Access from unknown device."), + AccessDecisionResult.Deny("Access from unknown device."), AuthOperation.Refresh => - AuthorizationResult.Challenge("Device verification required."), + AccessDecisionResult.Challenge("Device verification required."), - AuthOperation.Login => AuthorizationResult.Allow(), // login establishes device + AuthOperation.Login => AccessDecisionResult.Allow(), // login establishes device - _ => AuthorizationResult.Allow() + _ => AccessDecisionResult.Allow() }; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs index a2fc709b..5bcd7328 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs @@ -5,15 +5,15 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure { public sealed class DevicePresenceInvariant : IAuthorityInvariant { - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) { if (context.Device is null) - return AuthorizationResult.Deny("Device information is required."); + return AccessDecisionResult.Deny("Device information is required."); } - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs index a3eedf60..cb9e14c6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs @@ -6,22 +6,22 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure { public sealed class ExpiredSessionInvariant : IAuthorityInvariant { - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { if (context.Operation == AuthOperation.Login) - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); var session = context.Session; if (session is null) - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); if (session.State == SessionState.Expired) { - return AuthorizationResult.Deny("Session has expired."); + return AccessDecisionResult.Deny("Session has expired."); } - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs index 1929bb5a..7d8fe9a5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs @@ -6,15 +6,15 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure { public sealed class InvalidOrRevokedSessionInvariant : IAuthorityInvariant { - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { if (context.Operation == AuthOperation.Login) - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); var session = context.Session; if (session is null) - return AuthorizationResult.Deny("Session is required for this operation."); + return AccessDecisionResult.Deny("Session is required for this operation."); if (session.State == SessionState.Invalid || session.State == SessionState.NotFound || @@ -22,10 +22,10 @@ public AuthorizationResult Decide(AuthContext context) session.State == SessionState.SecurityMismatch || session.State == SessionState.DeviceMismatch) { - return AuthorizationResult.Deny($"Session state is invalid: {session.State}"); + return AccessDecisionResult.Deny($"Session state is invalid: {session.State}"); } - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs index 50960413..459e4ca8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs @@ -7,33 +7,33 @@ public sealed class AuthModeOperationPolicy : IAuthorityPolicy { public bool AppliesTo(AuthContext context) => true; // Applies to all contexts - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { return context.Mode switch { UAuthMode.PureOpaque => DecideForPureOpaque(context), UAuthMode.PureJwt => DecideForPureJwt(context), - UAuthMode.Hybrid => AuthorizationResult.Allow(), - UAuthMode.SemiHybrid => AuthorizationResult.Allow(), + UAuthMode.Hybrid => AccessDecisionResult.Allow(), + UAuthMode.SemiHybrid => AccessDecisionResult.Allow(), - _ => AuthorizationResult.Deny("Unsupported authentication mode.") + _ => AccessDecisionResult.Deny("Unsupported authentication mode.") }; } - private static AuthorizationResult DecideForPureOpaque(AuthContext context) + private static AccessDecisionResult DecideForPureOpaque(AuthContext context) { if (context.Operation == AuthOperation.Refresh) - return AuthorizationResult.Deny("Refresh operation is not supported in PureOpaque mode."); + return AccessDecisionResult.Deny("Refresh operation is not supported in PureOpaque mode."); - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } - private static AuthorizationResult DecideForPureJwt(AuthContext context) + private static AccessDecisionResult DecideForPureJwt(AuthContext context) { if (context.Operation == AuthOperation.Access) - return AuthorizationResult.Deny("Session-based access is not supported in PureJwt mode."); + return AccessDecisionResult.Deny("Session-based access is not supported in PureJwt mode."); - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs new file mode 100644 index 00000000..3c61b2fa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public interface IInMemoryUserIdProvider + { + TUserId GetAdminUserId(); + TUserId GetUserUserId(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs index 26d0d1eb..44465ac5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs @@ -33,11 +33,14 @@ public string ToString(TUserId id) { return id switch { - int v => v.ToString(CultureInfo.InvariantCulture), - long v => v.ToString(CultureInfo.InvariantCulture), + UserKey v => v.Value, Guid v => v.ToString("N"), string v => v, - _ => JsonSerializer.Serialize(id) + 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.") }; } @@ -62,11 +65,11 @@ public TUserId FromString(string value) { return typeof(TUserId) switch { - 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), + 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(UserKey) => (TUserId)(object)UserKey.FromString(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 UAuthInternalException("Cannot deserialize TUserId") @@ -92,8 +95,7 @@ public bool TryFromString(string value, out TUserId? id) /// /// Binary data representing the user id. /// The reconstructed user id. - public TUserId FromBytes(byte[] binary) => - FromString(Encoding.UTF8.GetString(binary)); + public TUserId FromBytes(byte[] binary) => FromString(Encoding.UTF8.GetString(binary)); public bool TryFromBytes(byte[] binary, out TUserId? id) { diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs new file mode 100644 index 00000000..21f731ee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + 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/UserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs deleted file mode 100644 index a5ad9a10..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Infrastructure -{ - public sealed class UserRecord - { - public required TUserId Id { get; init; } - public required string Username { get; init; } - public required string PasswordHash { get; init; } - public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; - public bool RequiresMfa { get; init; } - public bool IsActive { get; init; } = true; - public DateTimeOffset CreatedAt { get; init; } - public bool IsDeleted { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index b2c02f8f..6b3e618d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Auth diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAccessContextFactory.cs new file mode 100644 index 00000000..9ff7b684 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAccessContextFactory.cs @@ -0,0 +1,53 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Contracts; +using System.Collections.ObjectModel; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + internal sealed class DefaultAccessContextFactory : IAccessContextFactory + { + private readonly IUserRoleStore _roleStore; + + public DefaultAccessContextFactory(IUserRoleStore roleStore) + { + _roleStore = roleStore; + } + + public async Task CreateAsync(AuthFlowContext authFlow, string action, string resource, string? resourceId = null, string? resourceTenantId = null, IDictionary? attributes = null, CancellationToken ct = default) + { + 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 is not null + ? new Dictionary(attributes) + : new Dictionary(); + + if (authFlow.IsAuthenticated && authFlow.UserKey is not null) + { + var roles = await _roleStore.GetRolesAsync(authFlow.TenantId, authFlow.UserKey.Value, ct); + attrs["roles"] = roles; + } + + return new AccessContext + { + ActorUserKey = authFlow.UserKey, + ActorTenantId = authFlow.TenantId, + IsAuthenticated = authFlow.IsAuthenticated, + IsSystemActor = false, + + Resource = resource, + ResourceId = resourceId, + ResourceTenantId = resourceTenantId ?? authFlow.TenantId, + + Action = action, + + Attributes = attrs.Count > 0 + ? new ReadOnlyDictionary(attrs) + : EmptyAttributes.Instance + }; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthContextFactory.cs new file mode 100644 index 00000000..8d11f2b9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthContextFactory.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + internal sealed class DefaultAuthContextFactory : IAuthContextFactory + { + private readonly IAuthFlowContextAccessor _flow; + private readonly IClock _clock; + + public DefaultAuthContextFactory(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/DefaultAuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs index ca13cf15..2340fd38 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs @@ -9,10 +9,7 @@ internal sealed class DefaultAuthFlow : IAuthFlow private readonly IAuthFlowContextFactory _factory; private readonly DefaultAuthFlowContextAccessor _accessor; - public DefaultAuthFlow( - IHttpContextAccessor http, - IAuthFlowContextFactory factory, - IAuthFlowContextAccessor accessor) + public DefaultAuthFlow(IHttpContextAccessor http, IAuthFlowContextFactory factory, IAuthFlowContextAccessor accessor) { _http = http; _factory = factory; 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..4bc5aaad --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAccessContextFactory.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + public interface IAccessContextFactory + { + Task CreateAsync(AuthFlowContext authFlow, string action, string resource, string? resourceId = null, string? resourceTenantId = null, IDictionary? attributes = null, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs index 5f478587..a2fcc734 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authentication; +using CodeBeam.UltimateAuth.Server.Defaults; +using Microsoft.AspNetCore.Authentication; namespace CodeBeam.UltimateAuth.Server.Authentication; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs index bccddfa6..107c95d8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -55,32 +57,47 @@ protected override async Task HandleAuthenticateAsync() if (!result.IsValid || result.UserKey is null) return AuthenticateResult.NoResult(); - var principal = CreatePrincipal(result); - var ticket = new AuthenticationTicket(principal,UAuthCookieDefaults.AuthenticationScheme); + var principal = result.Claims.ToClaimsPrincipal(UAuthCookieDefaults.AuthenticationScheme); + return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthCookieDefaults.AuthenticationScheme)); - return AuthenticateResult.Success(ticket); + + //var principal = CreatePrincipal(result); + //var ticket = new AuthenticationTicket(principal,UAuthCookieDefaults.AuthenticationScheme); + + //return AuthenticateResult.Success(ticket); } private static ClaimsPrincipal CreatePrincipal(SessionValidationResult result) { - var claims = new List - { - new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value), - new Claim("uauth:session_id", result.SessionId.ToString()) - }; - - if (!string.IsNullOrEmpty(result.TenantId)) - { - claims.Add(new Claim("uauth:tenant", result.TenantId)); - } - - // Session claims (snapshot) - foreach (var (key, value) in result.Claims.AsDictionary()) - { - claims.Add(new Claim(key, value)); - } - - var identity = new ClaimsIdentity(claims, UAuthCookieDefaults.AuthenticationScheme); + //var claims = new List + //{ + // new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value), + // new Claim("uauth:session_id", result.SessionId.ToString()) + //}; + + //if (!string.IsNullOrEmpty(result.TenantId)) + //{ + // claims.Add(new Claim("uauth:tenant", result.TenantId)); + //} + + //// Session claims (snapshot) + //foreach (var (key, value) in result.Claims.AsDictionary()) + //{ + // if (key == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role") + // { + // foreach (var role in value.Split(',')) + // claims.Add(new Claim(ClaimTypes.Role, role)); + // } + // else + // { + // claims.Add(new Claim(key, value)); + // } + //} + + //var identity = new ClaimsIdentity(claims, UAuthCookieDefaults.AuthenticationScheme); + //return new ClaimsPrincipal(identity); + + var identity = new ClaimsIdentity(result.Claims.ToClaims(), UAuthCookieDefaults.AuthenticationScheme); return new ClaimsPrincipal(identity); } diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index 442f314e..a8340356 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -18,11 +18,14 @@ + + - - - - + + + + + diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs new file mode 100644 index 00000000..111ab057 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs @@ -0,0 +1,49 @@ +namespace CodeBeam.UltimateAuth.Server.Defaults +{ + public static class UAuthActions + { + public static class Users + { + public const string Create = "users.create"; + public const string Delete = "users.delete"; + public const string ChangeStatus = "users.status.change"; + } + + public static class UserProfiles + { + 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 Get = "users.identifiers.get"; + public const string Change = "users.identifiers.change"; + public const string Verify = "users.identifiers.verify"; + public const string Delete = "users.identifiers.delete"; + } + + public static class Credentials + { + public const string List = "credentials.list"; + public const string Add = "credentials.add"; + public const string Change = "credentials.change"; + public const string Revoke = "credentials.revoke"; + public const string Activate = "credentials.activate"; + public const string Delete = "credentials.delete"; + } + + public static class Authorization + { + public static class Roles + { + public const string Read = "authorization.roles.read"; + public const string Assign = "authorization.roles.assign"; + public const string Remove = "authorization.roles.remove"; + } + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthCookieDefaults.cs similarity index 65% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieDefaults.cs rename to src/CodeBeam.UltimateAuth.Server/Defaults/UAuthCookieDefaults.cs index 0fe1e11c..3f745cf0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthCookieDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthCookieDefaults.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Authentication; +namespace CodeBeam.UltimateAuth.Server.Defaults; public static class UAuthCookieDefaults { 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..53460a6f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IAuthorizationEndpointHandler + { + Task CheckAsync(HttpContext ctx); + Task GetRolesAsync(UserKey userKey, HttpContext ctx); + Task AssignRoleAsync(UserKey userKey, HttpContext ctx); + Task RemoveRoleAsync(UserKey userKey, 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..352e65ec --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface ICredentialEndpointHandler + { + Task GetAllAsync(HttpContext ctx); + Task AddAsync(HttpContext ctx); + Task ChangeAsync(string type, HttpContext ctx); + Task RevokeAsync(string type, HttpContext ctx); + Task ActivateAsync(string type, HttpContext ctx); + Task DeleteAsync(string type, HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs new file mode 100644 index 00000000..08cdbe69 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IUserLifecycleEndpointHandler + { + Task CreateAsync(HttpContext ctx); + Task ChangeStatusAsync(HttpContext ctx); + Task DeleteAsync(HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs new file mode 100644 index 00000000..7717e26d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IUserProfileAdminEndpointHandler + { + Task GetAsync(UserKey userKey, HttpContext ctx); + Task UpdateAsync(UserKey userKey, HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs new file mode 100644 index 00000000..afc9e364 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IUserProfileEndpointHandler + { + Task GetAsync(HttpContext ctx); + Task UpdateAsync(HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index 9131458d..bb215f45 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -53,13 +53,11 @@ public async Task LoginAsync(HttpContext ctx) if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret)) return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, authFlow.OriginalOptions); - var tenantCtx = ctx.GetTenantContext(); - var flowRequest = new LoginRequest { Identifier = identifier, Secret = secret, - TenantId = tenantCtx.TenantId, + TenantId = authFlow.TenantId, At = _clock.UtcNow, Device = authFlow.Device, RequestTokens = shouldIssueTokens diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs index 5bd10ac6..f47f8a6a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 0d3ccc92..aed3688c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -19,7 +19,7 @@ public class UAuthEndpointRegistrar : IAuthEndpointRegistrar { public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) { - // Base: /auth + // Default base: /auth string basePrefix = options.RoutePrefix.TrimStart('/'); bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute; @@ -79,10 +79,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { var session = group.MapGroup("/session"); - session.MapGet("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) + session.MapPost("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) => await h.GetCurrentSessionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); - session.MapGet("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) + session.MapPost("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) => await h.GetAllSessionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); session.MapPost("/revoke/{sessionId}", async ([FromServices] ISessionManagementHandler h, string sessionId, HttpContext ctx) @@ -96,15 +96,93 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { var user = group.MapGroup(""); - user.MapGet("/userinfo", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + user.MapPost("/userinfo", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) => await h.GetUserInfoAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); - user.MapGet("/permissions", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + 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)); } + + if (options.EnableUserLifecycleEndpoints) + { + var users = group.MapGroup("/users"); + + users.MapPost("/create", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) + => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + users.MapPost("/status", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) + => await h.ChangeStatusAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + // Post is intended for Auth + users.MapPost("/delete", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) + => await h.DeleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + } + + if (options.EnableUserProfileEndpoints) + { + var userProfile = group.MapGroup("/users"); + + userProfile.MapPost("/me/get", async ([FromServices] IUserProfileEndpointHandler h, HttpContext ctx) + => await h.GetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfile)); + + userProfile.MapPost("/me/update", async ([FromServices] IUserProfileEndpointHandler h, HttpContext ctx) + => await h.UpdateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); + } + + if (options.EnableAdminChangeUserProfileEndpoints) + { + var admin = group.MapGroup("/admin/users"); + + admin.MapPost("/{userKey}/profile/get", async ([FromServices] IUserProfileAdminEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + admin.MapPost("/{userKey}/profile/update", async ([FromServices] IUserProfileAdminEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + } + + if (options.EnableCredentialsEndpoints) + { + var credentials = group.MapGroup("/credentials"); + + credentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.GetAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/post", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/update/{type}", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.RevokeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.ActivateAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/delete/{type}", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.DeleteAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + } + + if (options.EnableAuthorizationEndpoints) + { + var authz = group.MapGroup("/authorization"); + + authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + authz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + authz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + authz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs new file mode 100644 index 00000000..4b2c9b13 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs @@ -0,0 +1,26 @@ +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); + + public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) + { + if (!ctx.Request.HasJsonContentType()) + throw new InvalidOperationException("Request content type must be application/json."); + + if (ctx.Request.Body is null) + throw new InvalidOperationException("Request body is empty."); + + var result = await JsonSerializer.DeserializeAsync(ctx.Request.Body, JsonOptions, ct); + + if (result is null) + throw new InvalidOperationException("Request body could not be deserialized."); + + return result; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index daba2baf..2dc154ba 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -1,9 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; 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; +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; @@ -13,11 +19,12 @@ using CodeBeam.UltimateAuth.Server.Infrastructure.Hub; using CodeBeam.UltimateAuth.Server.Infrastructure.Session; using CodeBeam.UltimateAuth.Server.Issuers; +using CodeBeam.UltimateAuth.Server.Login; +using CodeBeam.UltimateAuth.Server.Login.Orchestrators; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; -using CodeBeam.UltimateAuth.Server.Users; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -31,12 +38,18 @@ public static class UAuthServerServiceCollectionExtensions public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services) { services.AddUltimateAuth(); + AddUsersInternal(services); + AddCredentialsInternal(services); + AddUltimateAuthPolicies(services); return services.AddUltimateAuthServerInternal(); } public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) { services.AddUltimateAuth(configuration); + AddUsersInternal(services); + AddCredentialsInternal(services); + AddUltimateAuthPolicies(services); services.Configure(configuration.GetSection("UltimateAuth:Server")); return services.AddUltimateAuthServerInternal(); @@ -45,6 +58,9 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action configure) { services.AddUltimateAuth(); + AddUsersInternal(services); + AddCredentialsInternal(services); + AddUltimateAuthPolicies(services); services.Configure(configure); return services.AddUltimateAuthServerInternal(); @@ -123,16 +139,15 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddScoped(); // Public resolver - services.AddScoped(); + services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); - services.AddScoped(typeof(IRefreshFlowService), typeof(DefaultRefreshFlowService)); - services.AddScoped(typeof(IUAuthSessionManager), typeof(UAuthSessionManager)); - services.AddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); + services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); + services.TryAddScoped(typeof(IRefreshFlowService), typeof(DefaultRefreshFlowService)); + services.TryAddScoped(typeof(IUAuthSessionManager), typeof(UAuthSessionManager)); - services.AddSingleton(); + services.TryAddSingleton(); // TODO: Allow custom cookie manager via options //services.AddSingleton(); @@ -154,20 +169,25 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); services.TryAddScoped(); - services.TryAddScoped(typeof(IUserAuthenticator<>), typeof(DefaultUserAuthenticator<>)); + //services.TryAddScoped(typeof(IUserAuthenticator<>), typeof(DefaultUserAuthenticator<>)); services.TryAddScoped(typeof(ISessionOrchestrator), typeof(UAuthSessionOrchestrator)); + services.TryAddScoped(typeof(ILoginOrchestrator<>), typeof(DefaultLoginOrchestrator<>)); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(typeof(ISessionQueryService), typeof(UAuthSessionQueryService)); services.TryAddScoped(typeof(IRefreshTokenResolver), typeof(DefaultRefreshTokenResolver)); services.TryAddScoped(typeof(ISessionTouchService), typeof(DefaultSessionTouchService)); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -176,64 +196,124 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddScoped(); services.TryAddSingleton(); - services.AddScoped(); + services.TryAddScoped(); - services.AddScoped(typeof(IRefreshTokenValidator), typeof(DefaultRefreshTokenValidator)); - services.AddScoped(); - services.AddScoped(typeof(IRefreshTokenRotationService), typeof(RefreshTokenRotationService)); + services.TryAddScoped(typeof(IRefreshTokenValidator), typeof(DefaultRefreshTokenValidator)); + services.TryAddScoped(); + services.TryAddScoped(typeof(IRefreshTokenRotationService), typeof(RefreshTokenRotationService)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); // ----------------------------- // ENDPOINTS // ----------------------------- services.AddHttpContextAccessor(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); // Endpoint handlers - services.AddScoped>(); + services.TryAddScoped>(); services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped>(); + services.TryAddScoped>(); services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped>(); + services.TryAddScoped>(); services.TryAddScoped(); //services.TryAddScoped(); //services.TryAddScoped(); //services.TryAddScoped(); + return services; + } + + internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) + { + if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) + throw new InvalidOperationException("UltimateAuth policies already registered."); + + var registry = new AccessPolicyRegistry(); + + DefaultPolicySet.Register(registry); + configure?.Invoke(registry); + services.AddSingleton(registry); + services.AddSingleton(sp => + { + var compiled = registry.Build(); + return new DefaultAccessPolicyProvider(compiled, sp); + }); + + services.TryAddScoped(sp => + { + var invariants = sp.GetServices(); + var globalPolicies = sp.GetServices(); + + return new DefaultAccessAuthority(invariants, globalPolicies); + }); + + services.TryAddScoped(); + return services; } + + // ========================= + // USERS (FRAMEWORK-REQUIRED) + // ========================= + internal static IServiceCollection AddUsersInternal(IServiceCollection services) + { + // Core user abstractions + services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + services.TryAddScoped(); + + // Security state + //services.TryAddScoped(typeof(IUserSecurityEvents<>), typeof(DefaultUserSecurityEvents<>)); + + // TODO: Move this into AddAuthorizaionInternal method + services.TryAddScoped(typeof(IUserClaimsProvider), typeof(DefaultAuthorizationClaimsProvider)); + + return services; + } + + // ========================= + // CREDENTIALS (FRAMEWORK-REQUIRED) + // ========================= + internal static IServiceCollection AddCredentialsInternal(IServiceCollection services) + { + services.TryAddScoped(); + return services; + } + + public static IServiceCollection AddUAuthServerInfrastructure(this IServiceCollection services) { // Flow orchestration @@ -243,9 +323,6 @@ public static IServiceCollection AddUAuthServerInfrastructure(this IServiceColle services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); - // User service - services.TryAddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); - // Endpoints services.TryAddSingleton(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultCredentialResponseWriter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultCredentialResponseWriter.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultPrimaryCredentialResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultPrimaryCredentialResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultPrimaryCredentialResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultAccessPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultAccessPolicyProvider.cs new file mode 100644 index 00000000..008ca145 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultAccessPolicyProvider.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 DefaultAccessPolicyProvider : IAccessPolicyProvider +{ + private readonly CompiledAccessPolicySet _set; + private readonly IServiceProvider _services; + + public DefaultAccessPolicyProvider(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/DefaultUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs deleted file mode 100644 index a2e7b7ab..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs +++ /dev/null @@ -1,50 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using System.Security.Claims; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class DefaultUserAuthenticator : IUserAuthenticator - { - private readonly IUAuthUserStore _userStore; - private readonly IUAuthPasswordHasher _passwordHasher; - - public DefaultUserAuthenticator(IUAuthUserStore userStore, IUAuthPasswordHasher passwordHasher) - { - _userStore = userStore; - _passwordHasher = passwordHasher; - } - - public async Task> AuthenticateAsync(string? tenantId, AuthenticationContext context, CancellationToken ct = default) - { - if (context is null) - throw new ArgumentNullException(nameof(context)); - - if (!string.Equals(context.CredentialType, "password", StringComparison.Ordinal)) - return UserAuthenticationResult.Fail(); - - var user = await _userStore.FindByUsernameAsync(tenantId, context.Identifier, ct); - - if (user is null || !user.IsActive) - return UserAuthenticationResult.Fail(); - - if (!_passwordHasher.Verify(context.Secret, user.PasswordHash)) - return UserAuthenticationResult.Fail(); - - var claims = (user.Claims ?? ClaimsSnapshot.Empty) - .With( - (ClaimTypes.NameIdentifier, user.Id.ToString()!), - (ClaimTypes.Name, user.Username), - ("uauth:username", user.Username) - ); - - return UserAuthenticationResult.Success( - user.Id, - claims, - user.RequiresMfa - ); - - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs new file mode 100644 index 00000000..3f4f539e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs @@ -0,0 +1,60 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class DefaultAccessAuthority : IAccessAuthority + { + private readonly IEnumerable _invariants; + private readonly IEnumerable _globalPolicies; + + public DefaultAccessAuthority(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/IAccessCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs new file mode 100644 index 00000000..a0dfdd31 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs @@ -0,0 +1,14 @@ +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..5f8e8d9e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs @@ -0,0 +1,10 @@ +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/RevokeAllSessionsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs new file mode 100644 index 00000000..cdffd578 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class RevokeAllUserSessionsCommand : ISessionCommand + { + public UserKey UserKey { get; } + + public RevokeAllUserSessionsCommand(UserKey userKey) + { + UserKey = userKey; + } + + // TODO: This method should call its own logic. Not revoke root. + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + { + await issuer.RevokeRootAsync(context.TenantId, UserKey, 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 index 4f0d7521..a4f272af 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs @@ -13,17 +13,9 @@ public RevokeRootCommand(UserKey userKey) UserKey = userKey; } - public async Task ExecuteAsync( - AuthContext context, - ISessionIssuer issuer, - CancellationToken ct) + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) { - await issuer.RevokeRootAsync( - context.TenantId, - UserKey, - context.At, - ct); - + await issuer.RevokeRootAsync(context.TenantId, UserKey, context.At, ct); return Unit.Value; } } 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..305c09b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +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; + + public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider) + { + _authority = authority; + _policyProvider = policyProvider; + } + + public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = _policyProvider.GetPolicies(context); + var decision = _authority.Decide(context, policies); + + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason); + + 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(); + + var policies = _policyProvider.GetPolicies(context); + var decision = _authority.Decide(context, policies); + + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason); + + if (decision.RequiresReauthentication) + throw new InvalidOperationException("Requires reauthentication."); + + return await command.ExecuteAsync(ct); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs 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..dcd8c17a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Middlewares; +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[UserMiddleware.UserContextKey] as AuthUserSnapshot; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs new file mode 100644 index 00000000..7836ef64 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs @@ -0,0 +1,34 @@ +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// Default implementation of the login authority. + /// Applies basic security checks for login attempts. + /// + public sealed class DefaultLoginAuthority : ILoginAuthority + { + public LoginDecision Decide(LoginDecisionContext context) + { + if (!context.CredentialsValid) + { + return LoginDecision.Deny("Invalid credentials."); + } + + if (!context.UserExists || context.UserKey is null) + { + return LoginDecision.Deny("Invalid credentials."); + } + + var state = context.SecurityState; + if (state is not null) + { + if (state.IsLocked) + return LoginDecision.Deny("user_is_locked"); + + if (state.RequiresReauthentication) + return LoginDecision.Challenge("reauth_required"); + } + + return LoginDecision.Allow(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs new file mode 100644 index 00000000..2df88ac8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs @@ -0,0 +1,166 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +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.Users; + +namespace CodeBeam.UltimateAuth.Server.Login.Orchestrators +{ + internal sealed class DefaultLoginOrchestrator : ILoginOrchestrator + { + private readonly ICredentialStore _credentialStore; // authentication + private readonly ICredentialValidator _credentialValidator; + private readonly IUserStore _users; // eligible + private readonly IUserSecurityStateProvider _userSecurityStateProvider; // runtime risk + private readonly ILoginAuthority _authority; + private readonly ISessionOrchestrator _sessionOrchestrator; + private readonly ITokenIssuer _tokens; + private readonly IUserClaimsProvider _claimsProvider; + private readonly IUserIdConverterResolver _userIdConverterResolver; + + public DefaultLoginOrchestrator( + ICredentialStore credentialStore, + ICredentialValidator credentialValidator, + IUserStore users, + IUserSecurityStateProvider userSecurityStateProvider, + ILoginAuthority authority, + ISessionOrchestrator sessionOrchestrator, + ITokenIssuer tokens, + IUserClaimsProvider claimsProvider, + IUserIdConverterResolver userIdConverterResolver) + { + _credentialStore = credentialStore; + _credentialValidator = credentialValidator; + _users = users; + _userSecurityStateProvider = userSecurityStateProvider; + _authority = authority; + _sessionOrchestrator = sessionOrchestrator; + _tokens = tokens; + _claimsProvider = claimsProvider; + _userIdConverterResolver = userIdConverterResolver; + } + + public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var now = request.At ?? DateTimeOffset.UtcNow; + + var credentials = await _credentialStore.FindByLoginAsync(request.TenantId, request.Identifier, ct); + var orderedCredentials = credentials + .OfType() + .Where(c => c.Security.IsUsable(now)) + .Cast>() + .ToList(); + + TUserId validatedUserId = default!; + bool credentialsValid = false; + + foreach (var credential in orderedCredentials) + { + var result = await _credentialValidator.ValidateAsync(credential, request.Secret, ct); + + if (result.IsValid) + { + validatedUserId = credential.UserId; + credentialsValid = true; + break; + } + } + + bool userExists = credentialsValid; + + IUserSecurityState? securityState = null; + UserKey? userKey = null; + + if (credentialsValid) + { + securityState = await _userSecurityStateProvider.GetAsync(request.TenantId, validatedUserId, ct); + var converter = _userIdConverterResolver.GetConverter(); + userKey = UserKey.FromString(converter.ToString(validatedUserId)); + } + + var user = await _users.FindByIdAsync(request.TenantId, validatedUserId); + + if (user is null || user.IsDeleted || !user.IsActive) + { + // Deliberately vague + return LoginResult.Failed(); + } + + var decisionContext = new LoginDecisionContext + { + TenantId = request.TenantId, + Identifier = request.Identifier, + CredentialsValid = credentialsValid, + UserExists = userExists, + UserKey = userKey, + SecurityState = securityState, + IsChained = request.ChainId is not null + }; + + var decision = _authority.Decide(decisionContext); + + if (decision.Kind == LoginDecisionKind.Deny) + return LoginResult.Failed(); + + if (decision.Kind == LoginDecisionKind.Challenge) + { + return LoginResult.Continue(new LoginContinuation + { + Type = LoginContinuationType.Mfa, + Hint = decision.Reason + }); + } + + if (userKey is not UserKey validUserKey) + { + return LoginResult.Failed(); + } + + var claims = await _claimsProvider.GetClaimsAsync(request.TenantId, validUserKey, ct); + + var sessionContext = new AuthenticatedSessionContext + { + TenantId = request.TenantId, + UserKey = validUserKey, + Now = now, + Device = request.Device, + Claims = claims, + ChainId = request.ChainId, + Metadata = SessionMetadata.Empty + }; + + 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 + { + TenantId = request.TenantId, + UserKey = validUserKey, + SessionId = issuedSession.Session.SessionId, + ChainId = request.ChainId, + Claims = claims.AsDictionary() + }; + + tokens = new AuthTokens + { + AccessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct), + RefreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct) + }; + } + + return LoginResult.Success(issuedSession.Session.SessionId, tokens); + + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs new file mode 100644 index 00000000..d05fac67 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs @@ -0,0 +1,17 @@ +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// 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/Login/ILoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs new file mode 100644 index 00000000..d22d605b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// 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); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs new file mode 100644 index 00000000..625d3113 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs @@ -0,0 +1,26 @@ +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// Represents the outcome of a login decision. + /// + public sealed class LoginDecision + { + public LoginDecisionKind Kind { get; } + public string? Reason { get; } + + private LoginDecision(LoginDecisionKind kind, string? reason = null) + { + Kind = kind; + Reason = reason; + } + + public static LoginDecision Allow() + => new(LoginDecisionKind.Allow); + + public static LoginDecision Deny(string reason) + => new(LoginDecisionKind.Deny, reason); + + public static LoginDecision Challenge(string reason) + => new(LoginDecisionKind.Challenge, reason); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs new file mode 100644 index 00000000..695d19d2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs @@ -0,0 +1,50 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users; + +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// Represents all information required by the login authority + /// to make a login decision. + /// + public sealed class LoginDecisionContext + { + /// + /// Gets the tenant identifier. + /// + public string? TenantId { 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; } + + /// + /// 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/Login/LoginDecisionKind.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs new file mode 100644 index 00000000..c1086d05 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Server.Login +{ + public enum LoginDecisionKind + { + Allow = 1, + Deny = 2, + Challenge = 3 + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 1388620f..6e3da9dc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -108,6 +108,14 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public bool? EnableSessionEndpoints { get; set; } = true; public bool? EnableUserInfoEndpoints { get; set; } = true; + public bool EnableUserLifecycleEndpoints { get; set; } = true; + public bool EnableUserProfileEndpoints { get; set; } = true; + public bool EnableAdminChangeUserProfileEndpoints { get; set; } = false; + public bool EnableCredentialsEndpoints { get; set; } = true; + public bool EnableAuthorizationEndpoints { get; set; } = true; + + public UserIdentifierOptions UserIdentifiers { get; set; } = new(); + /// /// If true, server will add anti-forgery headers /// and require proper request metadata. @@ -137,7 +145,7 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public Action? ConfigureServices { get; set; } - internal Dictionary> ModeConfigurations { get; } = new(); + internal Dictionary> ModeConfigurations { get; set; } = new(); internal UAuthServerOptions Clone() @@ -159,16 +167,23 @@ internal UAuthServerOptions Clone() AuthResponse = AuthResponse.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), + UserIdentifiers = UserIdentifiers.Clone(), EnableLoginEndpoints = EnableLoginEndpoints, EnablePkceEndpoints = EnablePkceEndpoints, EnableTokenEndpoints = EnableTokenEndpoints, EnableSessionEndpoints = EnableSessionEndpoints, EnableUserInfoEndpoints = EnableUserInfoEndpoints, + EnableUserLifecycleEndpoints = EnableUserLifecycleEndpoints, + EnableUserProfileEndpoints = EnableUserProfileEndpoints, + EnableAdminChangeUserProfileEndpoints = EnableAdminChangeUserProfileEndpoints, + EnableCredentialsEndpoints = EnableCredentialsEndpoints, + EnableAuthorizationEndpoints = EnableAuthorizationEndpoints, EnableAntiCsrfProtection = EnableAntiCsrfProtection, EnableLoginRateLimiting = EnableLoginRateLimiting, + ModeConfigurations = ModeConfigurations, OnConfigureEndpoints = OnConfigureEndpoints, ConfigureServices = ConfigureServices, CustomCookieManagerType = CustomCookieManagerType diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs new file mode 100644 index 00000000..0f63cc91 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class UserIdentifierOptions + { + 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 RequireEmailVerification { get; set; } = false; + public bool RequirePhoneVerification { get; set; } = false; + + public bool AllowAdminOverride { get; set; } = true; + public bool AllowUserOverride { get; set; } = true; + + internal UserIdentifierOptions Clone() => new() + { + AllowUsernameChange = AllowUsernameChange, + AllowMultipleUsernames = AllowMultipleUsernames, + AllowMultipleEmail = AllowMultipleEmail, + AllowMultiplePhone = AllowMultiplePhone, + RequireEmailVerification = RequireEmailVerification, + RequirePhoneVerification = RequirePhoneVerification, + AllowAdminOverride = AllowAdminOverride, + AllowUserOverride = AllowUserOverride + }; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs new file mode 100644 index 00000000..7e444b25 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class DefaultSessionService : ISessionService + { + private readonly ISessionOrchestrator _orchestrator; + private readonly IClock _clock; + + public DefaultSessionService(ISessionOrchestrator orchestrator, IClock clock) + { + _orchestrator = orchestrator; + _clock = clock; + } + + public Task RevokeAllAsync(AuthContext authContext, UserKey userKey, CancellationToken ct) + { + return _orchestrator.ExecuteAsync(authContext, new RevokeAllUserSessionsCommand(userKey), ct); + } + + public Task RevokeAllExceptChainAsync(AuthContext authContext, UserKey userKey, SessionChainId exceptChainId, CancellationToken ct) + { + return _orchestrator.ExecuteAsync(authContext, new RevokeAllChainsCommand(userKey, exceptChainId), ct); + } + + public Task RevokeRootAsync(AuthContext authContext, UserKey userKey, CancellationToken ct) + { + return _orchestrator.ExecuteAsync(authContext, new RevokeRootCommand(userKey), ct); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs index 64a97736..fa674213 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Services { public interface ISessionQueryService { diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 833c7b1d..6864311e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -6,13 +6,15 @@ using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Login; +using System; namespace CodeBeam.UltimateAuth.Server.Services { internal sealed class UAuthFlowService : IUAuthFlowService { private readonly IAuthFlowContextAccessor _authFlow; - private readonly IUAuthUserService _users; + private readonly ILoginOrchestrator _loginOrchestrator; private readonly ISessionOrchestrator _orchestrator; private readonly ISessionQueryService _queries; private readonly ITokenIssuer _tokens; @@ -21,7 +23,7 @@ internal sealed class UAuthFlowService : IUAuthFlowService public UAuthFlowService( IAuthFlowContextAccessor authFlow, - IUAuthUserService users, + ILoginOrchestrator loginOrchestrator, ISessionOrchestrator orchestrator, ISessionQueryService queries, ITokenIssuer tokens, @@ -29,7 +31,7 @@ public UAuthFlowService( IRefreshTokenValidator tokenValidator) { _authFlow = authFlow; - _users = users; + _loginOrchestrator = loginOrchestrator; _orchestrator = orchestrator; _queries = queries; _tokens = tokens; @@ -52,112 +54,17 @@ public Task ExternalLoginAsync(ExternalLoginRequest request, Cancel throw new NotImplementedException(); } - public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) { - var now = request.At ?? DateTimeOffset.UtcNow; - - var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); - - if (!auth.Succeeded) - return LoginResult.Failed(); - - var converter = _userIdConverterResolver.GetConverter(); - var userKey = UserKey.FromString(converter.ToString(auth.UserId!)); - var sessionContext = new AuthenticatedSessionContext - { - TenantId = request.TenantId, - UserKey = userKey, - Now = now, - Device = request.Device, - Claims = auth.Claims, - ChainId = request.ChainId, - Metadata = SessionMetadata.Empty // TODO: Check all SessionMetadata.Empty statements - }; - - var authContext = flow.ToAuthContext(now); - var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); - - bool shouldIssueTokens = request.RequestTokens; - - AuthTokens? tokens = null; - - if (shouldIssueTokens) - { - var tokenContext = new TokenIssuanceContext - { - TenantId = request.TenantId, - UserKey = userKey, - SessionId = issuedSession.Session.SessionId, - ChainId = request.ChainId, - Claims = auth.Claims.AsDictionary() - }; - - var access = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); - var refresh = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct); - - tokens = new AuthTokens { AccessToken = access, RefreshToken = refresh }; - } - - return LoginResult.Success(issuedSession.Session.SessionId, tokens); + return _loginOrchestrator.LoginAsync(flow, request, ct); } - public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) + public Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) { - var now = request.At ?? DateTimeOffset.UtcNow; - - var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); - - if (!auth.Succeeded) - return LoginResult.Failed(); - - var converter = _userIdConverterResolver.GetConverter(); - var userKey = UserKey.FromString(converter.ToString(auth.UserId!)); - var sessionContext = new AuthenticatedSessionContext - { - TenantId = request.TenantId, - UserKey = userKey, - Now = now, - Device = request.Device, - Claims = auth.Claims, - ChainId = request.ChainId, - Metadata = SessionMetadata.Empty - }; - - var authContext = flow.ToAuthContext(now); - var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); - - bool shouldIssueTokens = request.RequestTokens; - - AuthTokens? tokens = null; - - if (shouldIssueTokens) - { - var tokenContext = new TokenIssuanceContext - { - TenantId = request.TenantId, - UserKey = userKey, - SessionId = issuedSession.Session.SessionId, - ChainId = request.ChainId, - Claims = auth.Claims.AsDictionary() - }; - - - var effectiveFlow = execution.EffectiveClientProfile is null - ? flow - : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); - - var access = await _tokens.IssueAccessTokenAsync(effectiveFlow, tokenContext, ct); - - var refresh = await _tokens.IssueRefreshTokenAsync(effectiveFlow, tokenContext, RefreshTokenPersistence.Persist, ct); - - tokens = new AuthTokens - { - AccessToken = access, - RefreshToken = refresh - }; - } - - return LoginResult.Success(issuedSession.Session.SessionId, tokens); + var effectiveFlow = execution.EffectiveClientProfile is null + ? flow + : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); + return _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); } public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs similarity index 86% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs index 9bd03369..a6736a01 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs @@ -1,19 +1,24 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization; +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; +using System.ComponentModel.DataAnnotations; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Services { public sealed class UAuthSessionQueryService : ISessionQueryService { private readonly ISessionStoreKernelFactory _storeFactory; + private readonly IUserClaimsProvider _claimsProvider; private readonly UAuthServerOptions _options; - public UAuthSessionQueryService(ISessionStoreKernelFactory storeFactory, IOptions options) + public UAuthSessionQueryService(ISessionStoreKernelFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) { _storeFactory = storeFactory; + _claimsProvider = claimsProvider; _options = options.Value; } @@ -45,7 +50,8 @@ public async Task ValidateSessionAsync(SessionValidatio //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); - return SessionValidationResult.Active(context.TenantId, session.UserKey, session.SessionId, session.ChainId, root.RootId, session.Claims, boundDeviceId: session.Device.DeviceId); + var claims = await _claimsProvider.GetClaimsAsync(context.TenantId, session.UserKey, ct); + return SessionValidationResult.Active(context.TenantId, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, boundDeviceId: session.Device.DeviceId); } public Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs deleted file mode 100644 index f76f804b..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs +++ /dev/null @@ -1,41 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Users -{ - internal sealed class UAuthUserService : IUAuthUserService - { - private readonly IUserAuthenticator _authenticator; - - public UAuthUserService(IUserAuthenticator authenticator) - { - _authenticator = authenticator; - } - - public async Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken ct = default) - { - var context = new AuthenticationContext - { - Identifier = identifier, - Secret = secret, - CredentialType = "password" - }; - - return await _authenticator.AuthenticateAsync(tenantId, context, ct); - } - - // This method must not issue sessions or tokens - public async Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken ct = default) - { - var context = new AuthenticationContext - { - Identifier = request.Identifier, - Secret = request.Password, - CredentialType = "password" - }; - - var result = await _authenticator.AuthenticateAsync(request.TenantId,context, ct); - return result.Succeeded; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs deleted file mode 100644 index b03c400a..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Users -{ - /// - /// Administrative user management operations. - /// - public interface IUAuthUserManagementService - { - Task RegisterAsync(RegisterUserRequest request, CancellationToken cancellationToken = default); - - Task DeleteAsync(TUserId userId, CancellationToken cancellationToken = default); - - Task> GetByIdAsync( - TUserId userId, - CancellationToken ct = default); - - Task>> GetAllAsync( - CancellationToken ct = default); - - Task DisableAsync( - TUserId userId, - CancellationToken ct = default); - - Task EnableAsync( - TUserId userId, - CancellationToken ct = default); - - Task ResetPasswordAsync( - TUserId userId, - ResetPasswordRequest request, - CancellationToken ct = default); - - // TODO: Change password, Update user info, etc. - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs deleted file mode 100644 index 68dedea7..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - /// - /// User self-service operations (profile, password, MFA). - /// - public interface IUAuthUserProfileService - { - Task> GetCurrentAsync( - CancellationToken ct = default); - - Task UpdateProfileAsync( - UpdateProfileRequest request, - CancellationToken ct = default); - - Task ChangePasswordAsync( - ChangePasswordRequest request, - CancellationToken ct = default); - - Task ConfigureMfaAsync( - ConfigureMfaRequest request, - CancellationToken ct = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Users/Options/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Options/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Options/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Users/Services/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Services/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs deleted file mode 100644 index 87cec2b9..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class AdminUserFilter - { - public bool? IsActive { get; init; } - public bool? IsEmailConfirmed { get; init; } - - public string? Search { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs deleted file mode 100644 index b53dfb6a..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class ChangePasswordRequest - { - public required string CurrentPassword { get; init; } - public required string NewPassword { get; init; } - - /// - /// If true, other sessions will be revoked. - /// - public bool RevokeOtherSessions { get; init; } = true; - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs deleted file mode 100644 index ff2b1d4c..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class ConfigureMfaRequest - { - public bool Enable { get; init; } - - /// - /// Optional verification code when enabling MFA. - /// - public string? VerificationCode { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs deleted file mode 100644 index 672d176b..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class ResetPasswordRequest - { - public required string NewPassword { get; init; } - - /// - /// If true, all active sessions will be revoked. - /// - public bool RevokeSessions { get; init; } = true; - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs deleted file mode 100644 index f7688c95..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class UpdateProfileRequest - { - public string? Username { get; init; } - public string? Email { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs deleted file mode 100644 index 5a8de734..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class UserDto - { - public required TUserId UserId { get; init; } - - public string? Username { get; init; } - public string? Email { get; init; } - - public bool IsActive { get; init; } - public bool IsEmailConfirmed { get; init; } - - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? LastLoginAt { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs deleted file mode 100644 index 141d34a7..00000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class UserProfileDto - { - public required TUserId UserId { get; init; } - - public string? Username { get; init; } - public string? Email { get; init; } - - public bool IsEmailConfirmed { get; init; } - - public DateTimeOffset CreatedAt { get; init; } - } -} 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..e03d7456 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs new file mode 100644 index 00000000..10baf330 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record PermissionDto +{ + public required string Value { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs new file mode 100644 index 00000000..cc50b191 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record RoleDto +{ + public required string Name { get; init; } +} 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..a4bf9285 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts +{ + public sealed class AssignRoleRequest + { + public required string Role { 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..078a504f --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts +{ + public sealed class 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/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/UserRolesResponse.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs new file mode 100644 index 00000000..17afba35 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts +{ + public sealed record UserRolesResponse + { + public required UserKey UserKey { get; init; } + public required IReadOnlyCollection Roles { get; init; } + } + +} 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..c80fec61 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs new file mode 100644 index 00000000..3cbbf129 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory.Extensions +{ + public static class AuthorizationInMemoryExtensions + { + public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs new file mode 100644 index 00000000..e7d05618 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Authorization.InMemory +{ + public interface IAuthorizationSeeder + { + Task SeedAsync(CancellationToken ct = default); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs new file mode 100644 index 00000000..82af6b38 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory +{ + internal sealed class InMemoryAuthorizationSeeder : IAuthorizationSeeder + { + private readonly IUserRoleStore _roles; + private readonly IInMemoryUserIdProvider _ids; + + public InMemoryAuthorizationSeeder(IUserRoleStore roles, IInMemoryUserIdProvider ids) + { + _roles = roles; + _ids = ids; + } + + public async Task SeedAsync(CancellationToken ct = default) + { + var key = _ids.GetAdminUserId(); + await _roles.AssignAsync(null, key, "Admin", ct); + } + } +} 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..e10afdfc --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -0,0 +1,50 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory +{ + internal sealed class InMemoryUserRoleStore : IUserRoleStore + { + private readonly ConcurrentDictionary> _roles = new(); + + public InMemoryUserRoleStore(IInMemoryUserIdProvider ids) + { + var key = ids.GetAdminUserId(); + _roles[key] = new HashSet + { + "Admin" + }; + } + + public Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_roles.TryGetValue(userKey, out var set)) + return Task.FromResult>(set.ToArray()); + + return Task.FromResult>(Array.Empty()); + } + + public Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var set = _roles.GetOrAdd(userKey, _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + set.Add(role); + return Task.CompletedTask; + } + + public Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_roles.TryGetValue(userKey, out var set)) + set.Remove(role); + + return Task.CompletedTask; + } + } + +} 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..f42a8e77 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs new file mode 100644 index 00000000..4f7474a1 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + internal sealed class AssignUserRoleCommand : IAccessCommand + { + private readonly Func _execute; + private readonly IEnumerable _policies; + + public AssignUserRoleCommand(IEnumerable policies, Func execute) + { + _policies = policies; + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs new file mode 100644 index 00000000..58c51d2e --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + internal sealed class GetUserRolesCommand : IAccessCommand> + { + private readonly IEnumerable _policies; + private readonly Func>> _execute; + + public GetUserRolesCommand(IEnumerable policies, Func>> execute) + { + _policies = policies; + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs new file mode 100644 index 00000000..d380b76d --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + internal sealed class RemoveUserRoleCommand : IAccessCommand + { + private readonly Func _execute; + private readonly IEnumerable _policies; + + public RemoveUserRoleCommand(IEnumerable policies, Func execute) + { + _policies = policies; + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs new file mode 100644 index 00000000..2550b229 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs @@ -0,0 +1,7 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; + +public sealed class Role +{ + public required string Name { get; init; } + public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs new file mode 100644 index 00000000..41c69b18 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs @@ -0,0 +1,111 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + public sealed class DefaultAuthorizationEndpointHandler : IAuthorizationEndpointHandler + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAuthorizationService _authorization; + private readonly IUserRoleService _roles; + private readonly IAccessContextFactory _accessContextFactory; + + public DefaultAuthorizationEndpointHandler(IAuthFlowContextAccessor authFlow, IAuthorizationService authorization, IUserRoleService roles, IAccessContextFactory accessContextFactory) + { + _authFlow = authFlow; + _authorization = authorization; + _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); + + 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 GetRolesAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.Read, + resource: "authorization.roles", + resourceId: userKey.Value + ); + + var roles = await _roles.GetRolesAsync(accessContext, userKey, 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.Assign, + resource: "authorization.roles", + resourceId: userKey.Value + ); + + await _roles.AssignAsync(accessContext, userKey, req.Role, 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.Remove, + resource: "authorization.roles", + resourceId: userKey.Value + ); + + await _roles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + return Results.Ok(); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs new file mode 100644 index 00000000..8e49f8ca --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Server.Endpoints; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Authorization.Reference.Extensions +{ + public static class AuthorizationReferenceExtensions + { + public static IServiceCollection AddUltimateAuthAuthorizationReference(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs new file mode 100644 index 00000000..2d2f3bd9 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + public sealed class DefaultRolePermissionResolver : IRolePermissionResolver + { + private static readonly IReadOnlyDictionary _map + = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["admin"] = new[] + { + new Permission("*") + }, + ["user"] = new[] + { + new Permission("profile.read"), + new Permission("profile.update") + } + }; + + public Task> ResolveAsync(string? tenantId, IEnumerable roles, CancellationToken ct = default) + { + var result = new List(); + + foreach (var role in roles) + { + if (_map.TryGetValue(role, out var perms)) + result.AddRange(perms); + } + + return Task.FromResult>(result); + } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs new file mode 100644 index 00000000..a7aae0fa --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + public sealed class DefaultUserPermissionStore : IUserPermissionStore + { + private readonly IUserRoleStore _roles; + private readonly IRolePermissionResolver _resolver; + + public DefaultUserPermissionStore(IUserRoleStore roles, IRolePermissionResolver resolver) + { + _roles = roles; + _resolver = resolver; + } + + public async Task> GetPermissionsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + var roles = await _roles.GetRolesAsync(tenantId, userKey, ct); + return await _resolver.ResolveAsync(tenantId, roles, ct); + } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs new file mode 100644 index 00000000..78acfa89 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs @@ -0,0 +1,37 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Abstractions; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + internal sealed class DefaultAuthorizationService : IAuthorizationService + { + private readonly IAccessPolicyProvider _policyProvider; + private readonly IAccessAuthority _accessAuthority; + + public DefaultAuthorizationService(IAccessPolicyProvider policyProvider, IAccessAuthority accessAuthority) + { + _policyProvider = policyProvider; + _accessAuthority = accessAuthority; + } + + public Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = _policyProvider.GetPolicies(context); + var decision = _accessAuthority.Decide(context, policies); + + if (decision.RequiresReauthentication) + return Task.FromResult(AuthorizationResult.ReauthRequired()); + + return Task.FromResult( + decision.IsAllowed + ? AuthorizationResult.Allow() + : AuthorizationResult.Deny(decision.DenyReason) + ); + } + + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultUserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultUserRoleService.cs new file mode 100644 index 00000000..08129ed4 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultUserRoleService.cs @@ -0,0 +1,62 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + internal sealed class DefaultUserRoleService : IUserRoleService + { + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserRoleStore _store; + + public DefaultUserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore store) + { + _accessOrchestrator = accessOrchestrator; + _store = store; + } + + public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(role)) + throw new ArgumentException("role_empty", nameof(role)); + + var cmd = new AssignUserRoleCommand(Array.Empty(), + async innerCt => + { + await _store.AssignAsync(context.ResourceTenantId, targetUserKey, role, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(role)) + throw new ArgumentException("role_empty", nameof(role)); + + var cmd = new RemoveUserRoleCommand(Array.Empty(), + async innerCt => + { + await _store.RemoveAsync(context.ResourceTenantId, targetUserKey, role, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + + public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new GetUserRolesCommand(Array.Empty(), + innerCt => _store.GetRolesAsync(context.ResourceTenantId, targetUserKey, innerCt)); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + } +} 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..a3de9c7f --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs @@ -0,0 +1,11 @@ +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/Abstractions/IRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs new file mode 100644 index 00000000..7042d45b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; + +namespace CodeBeam.UltimateAuth.Authorization +{ + public interface IRolePermissionResolver + { + Task> ResolveAsync(string? tenantId, IEnumerable roles, CancellationToken ct = default); + } +} 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..f093d400 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IUserPermissionStore +{ + Task> GetPermissionsAsync(string? tenantId, 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..b335d236 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization +{ + public interface IUserRoleService + { + Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); + Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); + Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, 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..e2aae185 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization +{ + public interface IUserRoleStore + { + Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default); + Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default); + Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + } +} 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..e03d7456 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs new file mode 100644 index 00000000..e5dff350 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Authorization +{ + public sealed class DefaultAuthorizationClaimsProvider : IUserClaimsProvider + { + private readonly IUserRoleStore _roles; + private readonly IUserPermissionStore _permissions; + + public DefaultAuthorizationClaimsProvider(IUserRoleStore roles, IUserPermissionStore permissions) + { + _roles = roles; + _permissions = permissions; + } + + public async Task GetClaimsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + var roles = await _roles.GetRolesAsync(tenantId, userKey, ct); + var perms = await _permissions.GetPermissionsAsync(tenantId, userKey, ct); + + var builder = ClaimsSnapshot.Create(); + + foreach (var role in roles) + builder.Add(ClaimTypes.Role, role); + + foreach (var perm in perms) + builder.Add("uauth:permission", perm.Value); + + return builder.Build(); + } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs new file mode 100644 index 00000000..69dc0f07 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Domain; + +public readonly record struct Permission(string Value) +{ + public override string ToString() => Value; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs new file mode 100644 index 00000000..2a6848a4 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class PermissionAccessPolicy : IAccessPolicy +{ + private readonly IReadOnlySet _permissions; + private readonly string _operation; + + public PermissionAccessPolicy(IEnumerable permissions, string operation) + { + _permissions = permissions.Select(p => p.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); + _operation = operation; + } + + public bool AppliesTo(AccessContext context) => context.ActorUserKey is not null; + + public AccessDecision Decide(AccessContext context) + { + if (context.ActorUserKey is null) + return AccessDecision.Deny("unauthenticated"); + + return _permissions.Contains(_operation) + ? AccessDecision.Allow() + : AccessDecision.Deny("missing_permission"); + } +} 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..e2fbe39d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj @@ -0,0 +1,16 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs new file mode 100644 index 00000000..a4d8a2f6 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record CredentialDto( + CredentialType Type, + CredentialSecurityStatus Status, + DateTimeOffset CreatedAt, + DateTimeOffset? LastUsedAt, + DateTimeOffset? RestrictedUntil, + DateTimeOffset? ExpiresAt, + string? Source); +} 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..134dee3b --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialMetadata( + DateTimeOffset CreatedAt, + DateTimeOffset? LastUsedAt, + string? Source); 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..082e8f40 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed class CredentialSecurityState +{ + public CredentialSecurityStatus Status { get; } + public DateTimeOffset? RestrictedUntil { get; } + public DateTimeOffset? ExpiresAt { get; } + public string? Reason { get; } + + public CredentialSecurityState( + CredentialSecurityStatus status, + DateTimeOffset? restrictedUntil = null, + DateTimeOffset? expiresAt = null, + string? reason = null) + { + Status = status; + RestrictedUntil = restrictedUntil; + ExpiresAt = expiresAt; + Reason = reason; + } + + /// + /// Determines whether the credential can be used at the given time. + /// + public bool IsUsable(DateTimeOffset now) + { + if (Status == CredentialSecurityStatus.Expired) + return false; + + if (ExpiresAt is not null && ExpiresAt <= now) + return false; + + if ((Status == CredentialSecurityStatus.Locked || Status == CredentialSecurityStatus.Revoked) && RestrictedUntil is not null) + { + return RestrictedUntil <= now; + } + + if (Status == CredentialSecurityStatus.Locked || Status == CredentialSecurityStatus.Revoked) + return false; + + return true; + } +} 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..1deeb5f8 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public enum CredentialSecurityStatus +{ + Active = 0, + + Revoked = 10, + Locked = 20, + Expired = 30 +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs new file mode 100644 index 00000000..9b87807a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs @@ -0,0 +1,23 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +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/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs new file mode 100644 index 00000000..7b101443 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs @@ -0,0 +1,35 @@ +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/Request/AddCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs new file mode 100644 index 00000000..dd26c9d3 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record AddCredentialRequest() + { + public CredentialType Type { get; set; } + public required string Secret { get; set; } + public string? Source { get; set; } + } +} 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..85c5770e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ChangeCredentialRequest +{ + public CredentialType Type { get; init; } + + public string CurrentSecret { get; init; } = default!; + public string NewSecret { get; init; } = default!; +} 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..1e144536 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record ResetPasswordRequest + { + public UserKey UserKey { get; init; } = default!; + 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/RevokeAllCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs new file mode 100644 index 00000000..dda45bf3 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed class RevokeAllCredentialsRequest + { + public required UserKey UserKey { 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..ad049edc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record RevokeCredentialRequest( + DateTimeOffset? Until = null, + string? Reason = null); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs new file mode 100644 index 00000000..2ccd937b --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record SetInitialCredentialRequest +{ + /// + /// Credential type to initialize (Password, Passkey, External, etc.). + /// + public required CredentialType Type { get; init; } + + /// + /// Plain secret (password, passkey public data, external token reference). + /// Will be hashed / processed by the credential service. + /// + public required string Secret { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs new file mode 100644 index 00000000..bd530568 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ValidateCredentialsRequest +{ + public string Identifier { get; init; } = default!; + public string Secret { get; init; } = default!; + + public CredentialType? CredentialType { 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..b6956b44 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record AddCredentialResult( + bool Succeeded, + string? Error, + CredentialType? Type = null) + { + public static AddCredentialResult Success(CredentialType type) + => new(true, null, type); + + public static AddCredentialResult Fail(string error) + => new(false, error); + } +} 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..59250862 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ChangeCredentialResult( + bool Succeeded, + string? Error, + CredentialType? Type = null) +{ + public static ChangeCredentialResult Success(CredentialType type) + => new(true, null, type); + + public static ChangeCredentialResult Fail(string error) + => new(false, 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..acdac294 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record CredentialActionResult( + bool Succeeded, + string? Error) + { + public static CredentialActionResult Success() + => new(true, null); + + public static CredentialActionResult Fail(string error) + => new(false, 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..9ccd3c79 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialChangeResult( + bool Succeeded, + bool SecurityInvalidated, + string? FailureReason = null); 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..4952fda4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs @@ -0,0 +1,41 @@ +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; } + + /* ----------------- Helpers ----------------- */ + + 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..e861b999 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs @@ -0,0 +1,33 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record CredentialValidationResult( + bool IsValid, + bool RequiresReauthentication, + bool RequiresSecurityVersionIncrement, + string? FailureReason = null) + { + public static CredentialValidationResult Success( + bool requiresSecurityVersionIncrement = false) + => new( + IsValid: true, + RequiresReauthentication: false, + RequiresSecurityVersionIncrement: requiresSecurityVersionIncrement); + + public static CredentialValidationResult Failed( + string? reason = null, + bool requiresReauthentication = false) + => new( + IsValid: false, + RequiresReauthentication: requiresReauthentication, + RequiresSecurityVersionIncrement: false, + FailureReason: reason); + + public static CredentialValidationResult ReauthenticationRequired( + string? reason = null) + => new( + IsValid: false, + RequiresReauthentication: true, + RequiresSecurityVersionIncrement: false, + FailureReason: reason); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs new file mode 100644 index 00000000..c8018a51 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialValidationResultDto +{ + public bool IsValid { get; init; } + + public bool RequiresReauthentication { get; init; } + public bool RequiresSecurityVersionIncrement { get; init; } + + public string? FailureReason { get; init; } +} 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..0ad73e96 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs @@ -0,0 +1,5 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record GetCredentialsResult( + IReadOnlyCollection Credentials); +} 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 index e73e9e50..4ffa082d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -23,6 +23,9 @@ + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs index 382cb1f2..7c91dd85 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs @@ -2,11 +2,11 @@ namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore { - internal sealed class EfCoreAuthUser : IUser + internal sealed class EfCoreAuthUser : IAuthSubject { public TUserId UserId { get; } - IReadOnlyDictionary? IUser.Claims => null; + IReadOnlyDictionary? IAuthSubject.Claims => null; public EfCoreAuthUser(TUserId userId) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs index 8a6fb28f..1ede89b8 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs @@ -1,83 +1,83 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal sealed class EfCoreUserStore : IUAuthUserStore where TUser : class -{ - private readonly DbContext _db; - private readonly CredentialUserMapping _map; - - public EfCoreUserStore(DbContext db, IOptions> options) - { - _db = db; - _map = CredentialUserMappingBuilder.Build(options.Value); - } - - public async Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default) - { - var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); - - if (user is null || !_map.CanAuthenticate(user)) - return null; - - return new EfCoreAuthUser(_map.UserId(user)); - } - - public async Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default) - { - var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == username, ct); - - if (user is null || !_map.CanAuthenticate(user)) - return null; - - return new UserRecord - { - Id = _map.UserId(user), - Username = _map.Username(user), - PasswordHash = _map.PasswordHash(user), - IsActive = true, - CreatedAt = DateTimeOffset.UtcNow, - IsDeleted = false - }; - } - - public async Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default) - { - var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == login, ct); - - if (user is null || !_map.CanAuthenticate(user)) - return null; - - return new EfCoreAuthUser(_map.UserId(user)); - } - - public Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken ct = default) - { - return _db.Set() - .Where(u => _map.UserId(u)!.Equals(userId)) - .Select(u => _map.PasswordHash(u)) - .FirstOrDefaultAsync(ct); - } - - public Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default) - { - throw new NotSupportedException("Password updates are not supported by EfCoreUserStore. " + - "Use application-level user management services."); - } - - public async Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken ct = default) - { - var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); - return user is null ? 0 : _map.SecurityVersion(user); - } - - public Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default) - { - throw new NotSupportedException("Security version updates must be handled by the application."); - } - -} +//using CodeBeam.UltimateAuth.Core.Abstractions; +//using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Infrastructure; +//using Microsoft.EntityFrameworkCore; +//using Microsoft.Extensions.Options; + +//namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +//internal sealed class EfCoreUserStore : IUAuthUserStore where TUser : class +//{ +// private readonly DbContext _db; +// private readonly CredentialUserMapping _map; + +// public EfCoreUserStore(DbContext db, IOptions> options) +// { +// _db = db; +// _map = CredentialUserMappingBuilder.Build(options.Value); +// } + +// public async Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default) +// { +// var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); + +// if (user is null || !_map.CanAuthenticate(user)) +// return null; + +// return new EfCoreAuthUser(_map.UserId(user)); +// } + +// public async Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default) +// { +// var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == username, ct); + +// if (user is null || !_map.CanAuthenticate(user)) +// return null; + +// return new UserRecord +// { +// Id = _map.UserId(user), +// Username = _map.Username(user), +// PasswordHash = _map.PasswordHash(user), +// IsActive = true, +// CreatedAt = DateTimeOffset.UtcNow, +// IsDeleted = false +// }; +// } + +// public async Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default) +// { +// var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == login, ct); + +// if (user is null || !_map.CanAuthenticate(user)) +// return null; + +// return new EfCoreAuthUser(_map.UserId(user)); +// } + +// public Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken ct = default) +// { +// return _db.Set() +// .Where(u => _map.UserId(u)!.Equals(userId)) +// .Select(u => _map.PasswordHash(u)) +// .FirstOrDefaultAsync(ct); +// } + +// public Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default) +// { +// throw new NotSupportedException("Password updates are not supported by EfCoreUserStore. " + +// "Use application-level user management services."); +// } + +// public async Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken ct = default) +// { +// var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); +// return user is null ? 0 : _map.SecurityVersion(user); +// } + +// public Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default) +// { +// throw new NotSupportedException("Security version updates must be handled by the application."); +// } + +//} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs index 3f7e4f1f..060b8665 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -12,7 +12,7 @@ public static IServiceCollection AddUltimateAuthEfCoreCredentials, EfCoreUserStore>(); + //services.AddScoped, EfCoreUserStore>(); return services; } 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 index a34b1a73..dbc070bb 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj @@ -10,6 +10,9 @@ + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs new file mode 100644 index 00000000..413a15b2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -0,0 +1,197 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.Reference; +using System.Collections.Concurrent; + +internal sealed class InMemoryCredentialStore : ICredentialStore, ICredentialSecretStore where TUserId : notnull +{ + private readonly ConcurrentDictionary> _byLogin; + private readonly ConcurrentDictionary>> _byUser; + + private readonly IUAuthPasswordHasher _hasher; + private readonly IInMemoryUserIdProvider _userIdProvider; + + public InMemoryCredentialStore(IUAuthPasswordHasher hasher, IInMemoryUserIdProvider userIdProvider) + { + _hasher = hasher; + _userIdProvider = userIdProvider; + + _byLogin = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + _byUser = new ConcurrentDictionary>>(); + + SeedDefault(); + } + + private void SeedDefault() + { + SeedUser("admin", _userIdProvider.GetAdminUserId()); + SeedUser("user", _userIdProvider.GetUserUserId()); + } + + private void SeedUser(string login, TUserId userId) + { + var state = new InMemoryPasswordCredentialState + { + UserId = userId, + Login = login, + SecretHash = _hasher.Hash(login), + Security = new CredentialSecurityState(CredentialSecurityStatus.Active), + Metadata = new CredentialMetadata(DateTimeOffset.UtcNow, null, "seed") + }; + + _byLogin[login] = state; + _byUser[userId] = new List> { state }; + } + + public Task>> FindByLoginAsync(string? tenantId, string loginIdentifier, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_byLogin.TryGetValue(loginIdentifier, out var state)) + return Task.FromResult>>(Array.Empty>()); + + return Task.FromResult>>(new[] { Map(state) }); + } + + public Task>> GetByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_byUser.TryGetValue(userId, out var list)) + return Task.FromResult>>(Array.Empty>()); + + return Task.FromResult>>(list.Select(Map).Cast>().ToArray()); + } + + public Task>> GetByUserAndTypeAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_byUser.TryGetValue(userId, out var list)) + return Task.FromResult>>(Array.Empty>()); + + return Task.FromResult>>( + list.Where(c => c.Type == type) + .Select(Map) + .Cast>() + .ToArray()); + } + + public Task ExistsAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(_byUser.TryGetValue(userId, out var list) && list.Any(c => c.Type == type)); + } + + public Task AddAsync(string? tenantId, ICredential credential, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (credential is not PasswordCredential pwd) + throw new NotSupportedException("Only password credential supported in-memory."); + + var state = new InMemoryPasswordCredentialState + { + UserId = pwd.UserId, + Login = pwd.LoginIdentifier, + SecretHash = pwd.SecretHash, + Security = pwd.Security, + Metadata = pwd.Metadata + }; + + _byLogin[pwd.LoginIdentifier] = state; + _byUser.AddOrUpdate(pwd.UserId, + _ => new List> { state }, + (_, list) => + { + list.Add(state); + return list; + }); + + return Task.CompletedTask; + } + + public Task UpdateSecurityStateAsync(string? tenantId, TUserId userId, CredentialType type, CredentialSecurityState securityState, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_byUser.TryGetValue(userId, out var list)) + { + var state = list.FirstOrDefault(c => c.Type == type); + if (state != null) + state.Security = securityState; + } + + return Task.CompletedTask; + } + + public Task UpdateMetadataAsync(string? tenantId, TUserId userId, CredentialType type, CredentialMetadata metadata, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_byUser.TryGetValue(userId, out var list)) + { + var state = list.FirstOrDefault(c => c.Type == type); + if (state != null) + state.Metadata = metadata; + } + + return Task.CompletedTask; + } + + public Task SetAsync(string? tenantId, TUserId userId, CredentialType type, string secretHash, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_byUser.TryGetValue(userId, out var list)) + { + var state = list.FirstOrDefault(c => c.Type == type); + if (state != null) + state.SecretHash = secretHash; + } + + return Task.CompletedTask; + } + + public Task DeleteAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_byUser.TryGetValue(userId, out var list)) + { + var state = list.FirstOrDefault(c => c.Type == type); + if (state != null) + { + list.Remove(state); + _byLogin.TryRemove(state.Login, out _); + } + } + + return Task.CompletedTask; + } + + public Task DeleteByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_byUser.TryRemove(userId, out var list)) + { + foreach (var credential in list) + _byLogin.TryRemove(credential.Login, out _); + } + + return Task.CompletedTask; + } + + private static PasswordCredential Map(InMemoryPasswordCredentialState state) + => new( + userId: state.UserId, + loginIdentifier: state.Login, + secretHash: state.SecretHash, + security: state.Security, + metadata: state.Metadata); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs deleted file mode 100644 index 0f090eac..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs +++ /dev/null @@ -1,43 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory -{ - internal sealed class InMemoryCredentialUser : IUser - { - public UserKey UserId { get; init; } - public string Username { get; init; } - - public string PasswordHash { get; private set; } = default!; - - public long SecurityVersion { get; private set; } - - public bool IsActive { get; init; } = true; - - IReadOnlyDictionary? IUser.Claims => null; - - public InMemoryCredentialUser( - UserKey userId, - string username, - string passwordHash, - long securityVersion = 0, - bool isActive = true) - { - UserId = userId; - Username = username; - PasswordHash = passwordHash; - SecurityVersion = securityVersion; - IsActive = isActive; - } - - internal void SetPasswordHash(string passwordHash) - { - PasswordHash = passwordHash; - SecurityVersion++; - } - - internal void IncrementSecurityVersion() - { - SecurityVersion++; - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs deleted file mode 100644 index 116896ef..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory -{ - internal static class InMemoryCredentialSeeder - { - public static IReadOnlyCollection CreateDefaultUsers(IUAuthPasswordHasher passwordHasher) - { - var adminUserId = UserKey.New(); - var passwordHash = passwordHasher.Hash("Password!"); - - var admin = new InMemoryCredentialUser( - userId: adminUserId, - username: "Admin", - passwordHash: passwordHash, - securityVersion: 0, - isActive: true - ); - - return new[] { admin }; - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs new file mode 100644 index 00000000..c9c06d7d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory +{ + internal sealed class InMemoryPasswordCredentialState + { + public TUserId UserId { get; init; } = default!; + public CredentialType Type { get; } = CredentialType.Password; + + public string Login { get; init; } = default!; + public string SecretHash { get; set; } = default!; + + public CredentialSecurityState Security { get; set; } = default!; + public CredentialMetadata Metadata { get; set; } = default!; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs deleted file mode 100644 index 7b730c49..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Collections.Concurrent; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory -{ - internal sealed class InMemoryUserStore : IUAuthUserStore - { - private readonly ConcurrentDictionary _usersByUsername; - private readonly ConcurrentDictionary _usersById; - - public InMemoryUserStore(IEnumerable seededUsers) - { - _usersByUsername = new ConcurrentDictionary( - StringComparer.OrdinalIgnoreCase); - - _usersById = new ConcurrentDictionary(); - - foreach (var user in seededUsers) - { - _usersByUsername[user.Username] = user; - _usersById[user.UserId] = user; - } - } - - public Task?> FindByIdAsync( - string? tenantId, - UserKey userId, - CancellationToken token = default) - { - token.ThrowIfCancellationRequested(); - - _usersById.TryGetValue(userId, out var user); - return Task.FromResult?>(user is { IsActive: true } ? user : null); - } - - public Task?> FindByUsernameAsync( - string? tenantId, - string username, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (!_usersByUsername.TryGetValue(username, out var user) || user.IsActive is false) - return Task.FromResult?>(null); - - // Core’daki UserRecord’u kullanıyorsun; InMemory tarafı buna map eder. - var record = new UserRecord - { - Id = user.UserId, - Username = user.Username, - PasswordHash = user.PasswordHash, - // ClaimsSnapshot varsa burada Empty bırakılabilir. - // Claims = ClaimsSnapshot.Empty, - RequiresMfa = false, - IsActive = user.IsActive, - CreatedAt = DateTimeOffset.UtcNow, - IsDeleted = false - }; - - return Task.FromResult?>(record); - } - - public Task?> FindByLoginAsync( - string? tenantId, - string login, - CancellationToken token = default) - { - token.ThrowIfCancellationRequested(); - - _usersByUsername.TryGetValue(login, out var user); - return Task.FromResult?>(user is { IsActive: true } ? user : null); - } - - public Task GetPasswordHashAsync( - string? tenantId, - UserKey userId, - CancellationToken token = default) - { - token.ThrowIfCancellationRequested(); - - return Task.FromResult( - _usersById.TryGetValue(userId, out var user) - ? user.PasswordHash - : null); - } - - public Task SetPasswordHashAsync( - string? tenantId, - UserKey userId, - string passwordHash, - CancellationToken token = default) - { - token.ThrowIfCancellationRequested(); - - if (_usersById.TryGetValue(userId, out var user)) - { - user.SetPasswordHash(passwordHash); - } - - return Task.CompletedTask; - } - - public Task GetSecurityVersionAsync(string? tenantId, UserKey userId, CancellationToken token = default) - { - return Task.FromResult( - _usersById.TryGetValue(userId, out var user) - ? user.SecurityVersion - : 0L); - } - - public Task IncrementSecurityVersionAsync(string? tenantId, UserKey userId, CancellationToken token = default) - { - if (_usersById.TryGetValue(userId, out var user)) - { - user.IncrementSecurityVersion(); - } - - return Task.CompletedTask; - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs deleted file mode 100644 index b6532588..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory -{ - public static class ServiceCollectionExtensions - { - /// - /// Registers the in-memory credential store with a default seeded user. - /// Intended for development, testing, and reference implementations. - /// - public static IServiceCollection AddInMemoryCredentials(this IServiceCollection services) - { - services.AddSingleton(sp => - { - var hasher = sp.GetService() - ?? throw new InvalidOperationException( - "IUAuthPasswordHasher is not registered. " + - "Call AddUltimateAuthArgon2() or register a custom hasher."); - - return InMemoryCredentialSeeder.CreateDefaultUsers(hasher); - }); - - services.AddSingleton>(sp => - { - var users = sp.GetRequiredService>(); - return new InMemoryUserStore(users); - }); - - return services; - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs new file mode 100644 index 00000000..d8b541ff --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory.Extensions +{ + public static class UltimateAuthCredentialsInMemoryExtensions + { + public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) + { + services.TryAddScoped(typeof(InMemoryCredentialStore<>)); + services.TryAddScoped(typeof(ICredentialStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddScoped(typeof(ICredentialSecretStore<>), typeof(InMemoryCredentialStore<>)); + + return services; + } + } +} 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..6b2038f0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs new file mode 100644 index 00000000..5d3acf6d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class ActivateCredentialCommand : IAccessCommand +{ + private readonly IEnumerable _policies; + private readonly Func> _execute; + + public ActivateCredentialCommand(IEnumerable policies, Func> execute) + { + _policies = policies ?? Array.Empty(); + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs new file mode 100644 index 00000000..46c5d2d3 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference +{ + internal sealed class AddCredentialCommand : IAccessCommand + { + private readonly IEnumerable _policies; + private readonly Func> _execute; + + public AddCredentialCommand(IEnumerable policies, Func> execute) + { + _policies = policies ?? Array.Empty(); + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } + +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs new file mode 100644 index 00000000..12a3463d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class ChangeCredentialCommand: IAccessCommand +{ + private readonly IEnumerable _policies; + private readonly Func> _execute; + + public ChangeCredentialCommand(IEnumerable policies, Func> execute) + { + _policies = policies ?? Array.Empty(); + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs new file mode 100644 index 00000000..625b6ff1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class DeleteCredentialCommand : IAccessCommand +{ + private readonly IEnumerable _policies; + private readonly Func> _execute; + + public DeleteCredentialCommand(IEnumerable policies, Func> execute) + { + _policies = policies ?? Array.Empty(); + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs new file mode 100644 index 00000000..8083e12c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference +{ + internal sealed class GetAllCredentialsCommand : IAccessCommand + { + private readonly IEnumerable _policies; + private readonly Func> _execute; + + public GetAllCredentialsCommand(IEnumerable policies, Func> execute) + { + _policies = policies ?? Array.Empty(); + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context)=> _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs new file mode 100644 index 00000000..c99980e0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class RevokeCredentialCommand : IAccessCommand +{ + private readonly IEnumerable _policies; + private readonly Func> _execute; + + public RevokeCredentialCommand( IEnumerable policies, Func> execute) + { + _policies = policies ?? Array.Empty(); + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs new file mode 100644 index 00000000..f0976bcc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference +{ + internal sealed class SetInitialCredentialCommand : IAccessCommand + { + private readonly IEnumerable _policies; + private readonly Func _execute; + + public SetInitialCredentialCommand(IEnumerable policies, Func execute) + { + _policies = policies ?? Array.Empty(); + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} 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..59aec6ca --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class PasswordCredential : ILoginCredential, ISecretCredential, ISecurableCredential, ICredentialDescriptor +{ + public TUserId UserId { get; } + public CredentialType Type => CredentialType.Password; + + public string LoginIdentifier { get; } + public string SecretHash { get; } + + public CredentialSecurityState Security { get; } + public CredentialMetadata Metadata { get; } + + public PasswordCredential( + TUserId userId, + string loginIdentifier, + string secretHash, + CredentialSecurityState security, + CredentialMetadata metadata) + { + UserId = userId; + LoginIdentifier = loginIdentifier; + SecretHash = secretHash; + Security = security; + Metadata = metadata; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs new file mode 100644 index 00000000..b41074a7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs @@ -0,0 +1,161 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Credentials.Reference +{ + public sealed class DefaultCredentialEndpointHandler : ICredentialEndpointHandler + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUserCredentialsService _credentials; + + public DefaultCredentialEndpointHandler( + IAuthFlowContextAccessor authFlow, + IAccessContextFactory accessContextFactory, + IUserCredentialsService credentials) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _credentials = credentials; + } + + private bool TryGetAuthenticatedUser(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; + } + + public async Task GetAllAsync(HttpContext ctx) + { + if (!TryGetAuthenticatedUser(out var flow, out var error)) + return error!; + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.List, + 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 (!TryGetAuthenticatedUser(out var flow, out var error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.Add, + resource: "credentials", + resourceId: flow.UserKey.Value); + + var result = await _credentials.AddAsync( + accessContext, + request, + ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task ChangeAsync(string type, HttpContext ctx) + { + if (!TryGetAuthenticatedUser(out var flow, out var error)) + return error!; + + if (!CredentialTypeParser.TryParse(type, out var credentialType)) + return Results.BadRequest($"Unsupported credential type: {type}"); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.Change, + resource: "credentials", + resourceId: flow.UserKey.Value); + + var result = await _credentials.ChangeAsync( + accessContext, + credentialType, + request, + ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task RevokeAsync(string type, HttpContext ctx) + { + if (!TryGetAuthenticatedUser(out var flow, out var error)) + return error!; + + if (!CredentialTypeParser.TryParse(type, out var credentialType)) + return Results.BadRequest($"Unsupported credential type: {type}"); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.Revoke, + resource: "credentials", + resourceId: flow.UserKey.Value); + + await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } + + public async Task ActivateAsync(string type, HttpContext ctx) + { + if (!TryGetAuthenticatedUser(out var flow, out var error)) + return error!; + + if (!CredentialTypeParser.TryParse(type, out var credentialType)) + return Results.BadRequest($"Unsupported credential type: {type}"); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.Activate, + resource: "credentials", + resourceId: flow.UserKey.Value); + + await _credentials.ActivateAsync(accessContext, credentialType, ctx.RequestAborted); + return Results.NoContent(); + } + + public async Task DeleteAsync(string type, HttpContext ctx) + { + if (!TryGetAuthenticatedUser(out var flow, out var error)) + return error!; + + if (!CredentialTypeParser.TryParse(type, out var credentialType)) + return Results.BadRequest($"Unsupported credential type: {type}"); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.Delete, + resource: "credentials", + resourceId: flow.UserKey.Value); + + await _credentials.DeleteAsync(accessContext, credentialType, ctx.RequestAborted); + return Results.NoContent(); + } + } + +} 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..083a5f7a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Endpoints; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Credentials.Reference +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + } +} 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..568b7cfc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference.Internal +{ + internal interface IUserCredentialsInternalService + { + Task DeleteInternalAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs new file mode 100644 index 00000000..27eb76c3 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs @@ -0,0 +1,191 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class DefaultUserCredentialsService : IUserCredentialsService, IUserCredentialsInternalService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly ICredentialStore _credentials; + private readonly ICredentialSecretStore _secrets; + private readonly IUAuthPasswordHasher _hasher; + private readonly IClock _clock; + + public DefaultUserCredentialsService( + IAccessOrchestrator accessOrchestrator, + ICredentialStore credentials, + ICredentialSecretStore secrets, + IUAuthPasswordHasher hasher, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _credentials = credentials; + _secrets = secrets; + _hasher = hasher; + _clock = clock; + } + + public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new GetAllCredentialsCommand(Array.Empty(), + async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var creds = await _credentials.GetByUserAsync(context.ResourceTenantId, userKey, innerCt); + + var dtos = creds + .OfType() + .Select(c => new CredentialDto( + c.Type, + c.Security.Status, + c.Metadata.CreatedAt, + c.Metadata.LastUsedAt, + c.Security.RestrictedUntil, + c.Security.ExpiresAt, + c.Metadata.Source)) + .ToArray(); + + return new GetCredentialsResult(dtos); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + // ---------------- ADD ---------------- + + public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AddCredentialCommand(Array.Empty(), + async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var exists = await _credentials.ExistsAsync(context.ResourceTenantId, userKey, request.Type, innerCt); + + if (exists) + return AddCredentialResult.Fail("credential_already_exists"); + + var hash = _hasher.Hash(request.Secret); + + var credential = new PasswordCredential( + userId: userKey, + loginIdentifier: userKey.Value, + secretHash: hash, + security: new CredentialSecurityState(CredentialSecurityStatus.Active), + metadata: new CredentialMetadata( + _clock.UtcNow, + null, + request.Source)); + + await _credentials.AddAsync(context.ResourceTenantId, credential, innerCt); + + return AddCredentialResult.Success(request.Type); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + // ---------------- CHANGE ---------------- + + public async Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new ChangeCredentialCommand(Array.Empty(), + async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var hash = _hasher.Hash(request.NewSecret); + + await _secrets.SetAsync(context.ResourceTenantId, userKey, type, hash, innerCt); + return ChangeCredentialResult.Success(type); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + // ---------------- REVOKE ---------------- + + public async Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new RevokeCredentialCommand(Array.Empty(), + async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var security = new CredentialSecurityState( + CredentialSecurityStatus.Revoked, + restrictedUntil: request.Until, + expiresAt: null, + reason: request.Reason); + + await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new ActivateCredentialCommand(Array.Empty(), + async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var security = new CredentialSecurityState(CredentialSecurityStatus.Active); + await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new DeleteCredentialCommand(Array.Empty(), + async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + await _credentials.DeleteAsync(context.ResourceTenantId, userKey, type, innerCt); + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + // ---------------------------------------- + // INTERNAL ONLY - NEVER CALL THEM DIRECTLY + // ---------------------------------------- + async Task IUserCredentialsInternalService.DeleteInternalAsync(string? tenantId, UserKey userKey, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + await _credentials.DeleteByUserAsync(tenantId, userKey, ct); + return CredentialActionResult.Success(); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs new file mode 100644 index 00000000..b943d0a5 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IUserCredentialsService +{ + Task GetAllAsync(AccessContext context, CancellationToken ct = default); + + Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); + + Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default); + + Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default); + + Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default); + + Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default); +} 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..dabf8de3 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredential +{ + TUserId UserId { get; } + CredentialType Type { get; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs new file mode 100644 index 00000000..82a654a1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials +{ + public interface ICredentialDescriptor + { + CredentialType Type { get; } + CredentialSecurityState Security { get; } + CredentialMetadata Metadata { get; } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs new file mode 100644 index 00000000..f74ded1c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials +{ + public interface ICredentialSecretStore + { + Task SetAsync(string? tenantId, TUserId userId, CredentialType type, string secretHash, CancellationToken ct = default); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs new file mode 100644 index 00000000..7429b1eb --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials +{ + public interface ICredentialStore + { + Task>>FindByLoginAsync(string? tenantId, string loginIdentifier, CancellationToken ct = default); + Task>>GetByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + Task>>GetByUserAndTypeAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default); + Task AddAsync(string? tenantId, ICredential credential, CancellationToken ct = default); + Task UpdateSecurityStateAsync(string? tenantId, TUserId userId, CredentialType type, CredentialSecurityState securityState, CancellationToken ct = default); + Task UpdateMetadataAsync(string? tenantId, TUserId userId, CredentialType type, CredentialMetadata metadata, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default); + Task DeleteByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + Task ExistsAsync(string? tenantId, TUserId userId, CredentialType type, 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..9f1ccd2c --- /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/ILoginCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs new file mode 100644 index 00000000..f81f5bf8 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ILoginCredential : ICredential +{ + string LoginIdentifier { get; } +} 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..0902d43b --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs @@ -0,0 +1,7 @@ +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..ffc805e0 --- /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/Abstractions/ISecurableCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs new file mode 100644 index 00000000..89989e16 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials +{ + public interface ISecurableCredential + { + CredentialSecurityState Security { 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..ed91b1ae --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs new file mode 100644 index 00000000..4416aef0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials; + +public sealed class DefaultCredentialValidator : ICredentialValidator +{ + private readonly IUAuthPasswordHasher _passwordHasher; + private readonly IClock _clock; + + public DefaultCredentialValidator(IUAuthPasswordHasher passwordHasher, IClock clock) + { + _passwordHasher = passwordHasher; + _clock = clock; + } + + public Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (credential is ISecurableCredential 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/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..3110e446 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")] 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..eb3dffea --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -0,0 +1,22 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + + + + + 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..7450d420 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs @@ -0,0 +1,34 @@ +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..940e1011 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Policies.Registry; + +namespace CodeBeam.UltimateAuth.Policies.Defaults; + +internal static class DefaultPolicySet +{ + public static void Register(AccessPolicyRegistry registry) + { + // Everyone must be authenticated + registry.Add("", _ => new RequireAuthenticatedPolicy()); + + // Self operations + registry.Add("users.profile.", _ => new RequireSelfPolicy()); + registry.Add("credentials.self.", _ => new RequireSelfPolicy()); + + // Admin-only + registry.Add("admin.", _ => new RequireAdminPolicy()); + + // Self OR admin + registry.Add("users.", _ => new RequireSelfOrAdminPolicy()); + + // Global safety + registry.Add("", _ => new DenyCrossTenantPolicy()); + } +} 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..7cdf491a --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs @@ -0,0 +1,32 @@ +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..b9e832ca --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs @@ -0,0 +1,46 @@ +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 RequireAdmin() + => Add(); + + public IPolicyScopeBuilder RequireSelfOrAdmin() + => 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..3444934f --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IConditionalPolicyBuilder.cs @@ -0,0 +1,8 @@ +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..22d2eede --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs @@ -0,0 +1,8 @@ +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..44c2faed --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Policies +{ + public interface IPolicyScopeBuilder + { + IPolicyScopeBuilder RequireAuthenticated(); + IPolicyScopeBuilder RequireSelf(); + IPolicyScopeBuilder RequireAdmin(); + IPolicyScopeBuilder RequireSelfOrAdmin(); + 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..2ecc488c --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs @@ -0,0 +1,20 @@ +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..61c73fb6 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs @@ -0,0 +1,53 @@ +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 RequireAdmin() + => Add(); + + public IPolicyScopeBuilder RequireSelfOrAdmin() + => 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..696c0f2c --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/ConditionalAccessPolicy.cs @@ -0,0 +1,23 @@ +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/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/RequireAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs new file mode 100644 index 00000000..d926a28c --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class RequireAdminPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + if (!context.IsAuthenticated) + return AccessDecision.Deny("unauthenticated"); + + if (!context.Attributes.TryGetValue("roles", out var value)) + return AccessDecision.Deny("missing_roles"); + + if (value is not IReadOnlyCollection roles) + return AccessDecision.Deny("invalid_roles"); + + return roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) + ? AccessDecision.Allow() + : AccessDecision.Deny("admin_required"); + } + + public bool AppliesTo(AccessContext context) => true; +} 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..5e23dab6 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs @@ -0,0 +1,16 @@ +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) => true; +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs new file mode 100644 index 00000000..3da53e95 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class RequireSelfOrAdminPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + if (!context.IsAuthenticated) + return AccessDecision.Deny("unauthenticated"); + + if (context.IsSelfAction) + return AccessDecision.Allow(); + + if (context.Attributes.TryGetValue("roles", out var value) + && value is IReadOnlyCollection roles + && roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) + { + return AccessDecision.Allow(); + } + + return AccessDecision.Deny("self_or_admin_required"); + } + + public bool AppliesTo(AccessContext context) => true; +} 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..a92ab2bf --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs @@ -0,0 +1,19 @@ +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) => true; +} 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/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index a277e09c..6ddf7d4a 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -30,9 +30,9 @@ public string Hash(string password) return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; } - public bool Verify(string password, string hash) + public bool Verify(string hash, string secret) { - if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(hash)) + if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(hash)) return false; var parts = hash.Split('.'); @@ -42,7 +42,7 @@ public bool Verify(string password, string hash) var salt = Convert.FromBase64String(parts[0]); var expectedHash = Convert.FromBase64String(parts[1]); - var argon2 = CreateArgon2(password, salt); + var argon2 = CreateArgon2(secret, salt); var actualHash = argon2.GetBytes(expectedHash.Length); return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs index bf6c4919..cbce83da 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs @@ -1,14 +1,10 @@ using CodeBeam.UltimateAuth.Security.Argon2; -using CodeBeam.UltimateAuth.Server.Composition; -using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Server.Composition.Extensions; public static class UltimateAuthServerBuilderArgon2Extensions { - public static UltimateAuthServerBuilder UseArgon2( - this UltimateAuthServerBuilder builder, - Action? configure = null) + public static UltimateAuthServerBuilder UseArgon2(this UltimateAuthServerBuilder builder, Action? configure = null) { builder.Services.AddUltimateAuthArgon2(configure); return builder; diff --git a/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj b/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj similarity index 70% rename from src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj index 004336af..1f3e2def 100644 --- a/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj @@ -8,4 +8,8 @@ $(NoWarn);1591 + + + + 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..3a5b936b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs @@ -0,0 +1,10 @@ +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/UserAccessDecision.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs new file mode 100644 index 00000000..04926b51 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserAccessDecision( + bool IsAllowed, + bool RequiresReauthentication, + string? DenyReason = null); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs new file mode 100644 index 00000000..0a0bc2de --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UserIdentifierDto + { + public required UserIdentifierType Type { get; init; } + public required string Value { get; init; } + public bool IsPrimary { get; init; } + public bool IsVerified { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; init; } + } +} 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..57ef44fd --- /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 + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs new file mode 100644 index 00000000..bf652782 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UserMfaStatusDto + { + 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/UserProfileDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs new file mode 100644 index 00000000..45bf9951 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UserProfileDto + { + public string UserKey { get; init; } = default!; + + public string? UserName { get; init; } + public string? Email { get; init; } + + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + + public string? Phone { get; init; } + + public bool EmailVerified { get; init; } + public bool PhoneVerified { get; init; } + + public UserStatus Status { 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/Dtos/UserProfileInput.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs new file mode 100644 index 00000000..2a9f029d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserProfileInput +{ + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + public string? Email { get; init; } + public string? Phone { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs new file mode 100644 index 00000000..77f1c19a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public enum UserStatus + { + // Normal state + Active = 0, + + // User initiated + SelfSuspended = 10, + + // Administrative actions + Disabled = 20, + Suspended = 30, + + // Security / risk based + Locked = 40, + RiskHold = 50, + + // Lifecycle + PendingActivation = 60, + PendingVerification = 70, + + // Terminal (soft-delete) + Deactivated = 80, + + // Soft // TODO: User domain already have IsDeleted, this may remove + Deleted = 90 + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs new file mode 100644 index 00000000..7c5c9e28 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record BeginMfaSetupRequest + { + public MfaMethod Method { 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..cf6a530f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs @@ -0,0 +1,9 @@ +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/ChangeUserStatusRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs new file mode 100644 index 00000000..842a482b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed class ChangeUserStatusRequest + { + public required UserKey UserKey { get; init; } + public required UserStatus NewStatus { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs new file mode 100644 index 00000000..ab1ba705 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record CompleteMfaSetupRequest + { + public MfaMethod Method { get; init; } + public string VerificationCode { get; init; } = default!; + } +} 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..dc5fe86d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs @@ -0,0 +1,36 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record CreateUserRequest +{ + /// + /// Primary identifier (username, email, external id). + /// Interpretation is application-specific. + /// + public required string Identifier { get; init; } + + /// + /// Optional password. + /// If null, user may be invited or use external login. + /// + public string? Password { get; init; } + + public string? DisplayName { get; set; } + + public string? TenantId { get; init; } + + /// + /// Initial user status. + /// Defaults to Active. + /// + public UserStatus InitialStatus { get; init; } = UserStatus.Active; + + /// + /// Optional initial profile data. + /// + public UserProfileInput? Profile { get; init; } + + /// + /// Optional custom metadata. + /// + 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..33b95744 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record DeleteUserIdentifierRequest + { + public required UserIdentifierType Type { get; init; } + public required string Value { get; init; } + public DeleteMode Mode { get; init; } = DeleteMode.Soft; + } +} 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..0ed5ec45 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed class DeleteUserRequest + { + public required UserKey UserKey { get; init; } + public DeleteMode Mode { get; init; } = DeleteMode.Soft; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs new file mode 100644 index 00000000..63da5424 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record DisableMfaRequest + { + public MfaMethod? Method { get; init; } // null = all + } +} diff --git a/src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs index 78cd9960..e8482441 100644 --- a/src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts { /// /// Request to register a new user with credentials. 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..3503699b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs @@ -0,0 +1,11 @@ +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 string? PhoneNumber { get; init; } + + public IReadOnlyDictionary? Metadata { 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..5b642d14 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record VerifyUserIdentifierRequest + { + public required UserIdentifierType Type { get; init; } + public required string Code { 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..0285c0a3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs @@ -0,0 +1,9 @@ +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..a1e4b8e7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs @@ -0,0 +1,7 @@ +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..db7376d8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs @@ -0,0 +1,12 @@ +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..e62ec71e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs @@ -0,0 +1,11 @@ +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/IdentifierVerificationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs new file mode 100644 index 00000000..6c4b4464 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs @@ -0,0 +1,12 @@ +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/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..e355cd9a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs @@ -0,0 +1,42 @@ +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.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj b/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj new file mode 100644 index 00000000..fa0680d1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs new file mode 100644 index 00000000..a30ecc5c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Users.InMemory.Extensions +{ + public static class UltimateAuthUsersInMemoryExtensions + { + public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) + { + services.TryAddScoped, InMemoryUserStore>(); + services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddSingleton, InMemoryUserIdProvider>(); + + return services; + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs new file mode 100644 index 00000000..8ae70e3c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.InMemory +{ + public sealed class InMemoryUserIdProvider : IInMemoryUserIdProvider + { + private static readonly UserKey Admin = UserKey.FromString("admin"); + private static readonly UserKey User = UserKey.FromString("user"); + + public UserKey GetAdminUserId() => Admin; + public UserKey GetUserUserId() => User; + } + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs new file mode 100644 index 00000000..bd7b9ddb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Users; + +namespace CodeBeam.UltimateAuth.Users.InMemory +{ + internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider + { + public Task GetAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + // InMemory default: no MFA, no lockout, no risk signals + return Task.FromResult(null); + } + } +} 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..f0ba8dde --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -0,0 +1,133 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory +{ + internal sealed class InMemoryUserIdentifierStore : IUserIdentifierStore + { + private readonly ConcurrentDictionary<(string? TenantId, UserKey UserKey), List> _byUser = new(); + private readonly ConcurrentDictionary<(string? TenantId, UserIdentifierType Type, string Value), UserKey> _lookup = new(); + + public Task> GetAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenantId, userKey); + + if (_byUser.TryGetValue(key, out var list)) + return Task.FromResult>(list.ToArray()); + + return Task.FromResult>(Array.Empty()); + } + + public Task> GetByTypeAsync(string? tenantId, UserKey userKey, UserIdentifierType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenantId, userKey); + + if (_byUser.TryGetValue(key, out var list)) + { + var result = list.Where(x => x.Type == type).ToArray(); + return Task.FromResult>(result); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task SetAsync(string? tenantId, UserKey userKey, UserIdentifierRecord record, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var userKeyTuple = (tenantId, userKey); + var lookupKey = (tenantId, record.Type, record.Value); + + var list = _byUser.GetOrAdd(userKeyTuple, _ => new List()); + + // replace if same type+value exists + var existingIndex = list.FindIndex(x => x.Type == record.Type && StringComparer.OrdinalIgnoreCase.Equals(x.Value, record.Value)); + + if (existingIndex >= 0) + { + list[existingIndex] = record; + } + else + { + list.Add(record); + _lookup[lookupKey] = userKey; + } + + return Task.CompletedTask; + } + + public Task MarkVerifiedAsync(string? tenantId, UserKey userKey, UserIdentifierType type, DateTimeOffset verifiedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenantId, userKey); + + if (!_byUser.TryGetValue(key, out var list)) + return Task.CompletedTask; + + for (int i = 0; i < list.Count; i++) + { + if (list[i].Type == type && !list[i].VerifiedAt.HasValue) + { + list[i] = list[i] with + { + VerifiedAt = verifiedAt + }; + } + } + + return Task.CompletedTask; + } + + public Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenantId, type, value); + return Task.FromResult(_lookup.ContainsKey(key)); + } + + public Task DeleteAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, DeleteMode mode, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var userKeyTuple = (tenantId, userKey); + var lookupKey = (tenantId, type, value); + + if (!_byUser.TryGetValue(userKeyTuple, out var list)) + return Task.CompletedTask; + + var index = list.FindIndex(x => x.Type == type && StringComparer.OrdinalIgnoreCase.Equals(x.Value, value)); + + if (index < 0) + return Task.CompletedTask; + + var record = list[index]; + + if (mode == DeleteMode.Soft) + { + if (record.DeletedAt.HasValue) + return Task.CompletedTask; + + list[index] = record with + { + DeletedAt = DateTimeOffset.UtcNow + }; + } + else + { + list.RemoveAt(index); + _lookup.TryRemove(lookupKey, out _); + } + + return Task.CompletedTask; + } + } +} 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..6038c12c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -0,0 +1,150 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Domain; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +public sealed class InMemoryUserLifecycleStore : IUserLifecycleStore +{ + private readonly ConcurrentDictionary _users = new(); + private readonly IInMemoryUserIdProvider _idProvider; + private readonly IClock _clock; + + public InMemoryUserLifecycleStore(IInMemoryUserIdProvider idProvider, IClock clock) + { + _idProvider = idProvider; + _clock = clock; + SeedDefault(); + } + + private void SeedDefault() + { + CreateSeedUser(_idProvider.GetAdminUserId(), "admin"); + CreateSeedUser(_idProvider.GetUserUserId(), "user"); + } + + private void CreateSeedUser(UserKey userKey, string identifier) + { + var now = _clock.UtcNow; + + var profile = new ReferenceUserProfile + { + UserKey = userKey, + Email = identifier, + DisplayName = identifier == "admin" + ? "Administrator" + : "Standard User", + Status = UserStatus.Active, + IsDeleted = false, + CreatedAt = now, + UpdatedAt = now, + DeletedAt = null + }; + + _users.TryAdd( + new UserIdentity(null, userKey), + profile); + } + + public Task CreateAsync(string? tenantId, ReferenceUserProfile user, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(user); + + var identity = new UserIdentity(tenantId, user.UserKey); + + if (!_users.TryAdd(identity, InitializeUser(user))) + { + throw new InvalidOperationException($"User '{user.UserKey}' already exists in tenant '{tenantId ?? ""}'."); + } + + return Task.CompletedTask; + } + + public Task UpdateStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default) + { + var identity = new UserIdentity(tenantId, userKey); + + if (!_users.TryGetValue(identity, out var user)) + throw new InvalidOperationException($"User '{userKey}' does not exist."); + + if (user.IsDeleted) + throw new InvalidOperationException($"User '{userKey}' is deleted."); + + if (user.Status == status) + return Task.CompletedTask; + + if (!IsValidStatusTransition(user.Status, status)) + throw new InvalidOperationException($"Invalid status transition from '{user.Status}' to '{status}'."); + + user.Status = status; + user.UpdatedAt = DateTimeOffset.UtcNow; + + return Task.CompletedTask; + } + + public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var identity = new UserIdentity(tenantId, userKey); + + if (!_users.TryGetValue(identity, out var user)) + { + return Task.CompletedTask; + } + + switch (mode) + { + case DeleteMode.Soft: + if (user.IsDeleted) + return Task.CompletedTask; + + user.Status = UserStatus.Deleted; + user.IsDeleted = true; + user.DeletedAt = at; + user.UpdatedAt = at; + break; + + case DeleteMode.Hard: + _users.TryRemove(identity, out _); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown delete mode."); + } + + return Task.CompletedTask; + } + + private static ReferenceUserProfile InitializeUser(ReferenceUserProfile user) + { + return user with + { + Status = user.Status == default ? UserStatus.Active : user.Status, + CreatedAt = user.CreatedAt == default ? DateTimeOffset.UtcNow : user.CreatedAt, + UpdatedAt = DateTimeOffset.UtcNow, + IsDeleted = false, + DeletedAt = null + }; + } + + private static bool IsValidStatusTransition(UserStatus from, UserStatus to) + { + return from switch + { + UserStatus.Active => to is UserStatus.Suspended or UserStatus.Disabled, + UserStatus.Suspended => to is UserStatus.Active or UserStatus.Disabled, + UserStatus.Disabled => to is UserStatus.Active, + _ => false + }; + } + + private readonly record struct UserIdentity(string? TenantId, UserKey UserKey); +} 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..61008cfc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -0,0 +1,131 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Domain; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserProfileStore : IUserProfileStore +{ + private readonly ConcurrentDictionary _profiles = new(); + private readonly IInMemoryUserIdProvider _idProvider; + private readonly IClock _clock; + + internal IEnumerable AllProfiles => _profiles.Values; + + public InMemoryUserProfileStore(IInMemoryUserIdProvider idProvider, IClock clock) + { + _idProvider = idProvider; + _clock = clock; + SeedDefault(); + } + + private void SeedDefault() + { + SeedProfile(_idProvider.GetAdminUserId(), "Administrator"); + SeedProfile(_idProvider.GetUserUserId(), "Standard User"); + } + + private void SeedProfile(UserKey userKey, string displayName) + { + var now = _clock.UtcNow; + + _profiles[userKey] = new ReferenceUserProfile + { + UserKey = userKey, + DisplayName = displayName, + Status = UserStatus.Active, + CreatedAt = now, + UpdatedAt = now + }; + } + + public Task CreateAsync(string? tenantId, ReferenceUserProfile profile, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var mem = new ReferenceUserProfile + { + UserKey = profile.UserKey, + FirstName = profile.FirstName, + LastName = profile.LastName, + DisplayName = profile.DisplayName, + Email = profile.Email, + Status = profile.Status, + IsDeleted = profile.IsDeleted, + CreatedAt = profile.CreatedAt, + UpdatedAt = profile.UpdatedAt, + DeletedAt = profile.DeletedAt + }; + + if (!_profiles.TryAdd(profile.UserKey, mem)) + throw new InvalidOperationException($"User profile '{profile.UserKey}' already exists."); + + return Task.CompletedTask; + } + + public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) + return Task.FromResult(null); + + return Task.FromResult(Map(profile)); + } + + public Task UpdateAsync(string? tenantId, UserKey userKey, UpdateProfileRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) + throw new InvalidOperationException("User profile does not exist."); + + profile.FirstName = request.FirstName; + profile.LastName = request.LastName; + profile.DisplayName = request.DisplayName; + profile.UpdatedAt = DateTimeOffset.UtcNow; + + return Task.CompletedTask; + } + + public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (mode == DeleteMode.Hard) + { + _profiles.TryRemove(userKey, out _); + return Task.CompletedTask; + } + + if (!_profiles.TryGetValue(userKey, out var profile)) + throw new InvalidOperationException("User profile does not exist."); + + profile.IsDeleted = true; + profile.Status = UserStatus.Deleted; + profile.DeletedAt = DateTimeOffset.UtcNow; + profile.UpdatedAt = profile.DeletedAt; + + return Task.CompletedTask; + } + + private static ReferenceUserProfile Map(ReferenceUserProfile profile) + => new() + { + UserKey = profile.UserKey, + FirstName = profile.FirstName, + LastName = profile.LastName, + DisplayName = profile.DisplayName, + Email = profile.Email, + Status = profile.Status, + CreatedAt = profile.CreatedAt, + UpdatedAt = profile.UpdatedAt, + IsDeleted = profile.IsDeleted, + DeletedAt = profile.DeletedAt + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs new file mode 100644 index 00000000..32a46ae0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs @@ -0,0 +1,86 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserStore : IUserStore +{ + private readonly InMemoryUserLifecycleStore _lifecycle; + private readonly IUserProfileStore _profiles; + + public InMemoryUserStore( + InMemoryUserLifecycleStore lifecycle, + IUserProfileStore profiles) + { + _lifecycle = lifecycle; + _profiles = profiles; + } + + public async Task?> FindByIdAsync( + string? tenantId, + UserKey userId, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + throw new NotImplementedException(); + //var lifecycle = await _lifecycle.GetAsync(tenantId, userId, ct); + //if (lifecycle is null || lifecycle.IsDeleted) + // return null; + + //var profile = await _profiles.GetAsync(tenantId, userId, ct); + + //return new AuthUserRecord + //{ + // Id = userId, + // Identifier = + // profile?.Email ?? + // profile?.DisplayName ?? + // userId.ToString(), + + // IsActive = lifecycle.Status == UserStatus.Active, + // IsDeleted = lifecycle.IsDeleted, + // CreatedAt = lifecycle.CreatedAt, + // DeletedAt = lifecycle.DeletedAt + //}; + } + + public async Task?> FindByLoginAsync( + string? tenantId, + string login, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + // InMemory limitation: scan profiles + if (_profiles is not InMemoryUserProfileStore mem) + throw new InvalidOperationException("InMemory only"); + + var profile = mem.AllProfiles + .FirstOrDefault(p => + !p.IsDeleted && + !string.IsNullOrWhiteSpace(p.Email) && + string.Equals(p.Email, login, StringComparison.OrdinalIgnoreCase)); + + if (profile is null) + return null; + + return await FindByIdAsync(tenantId, profile.UserKey, ct); + } + + public async Task ExistsAsync( + string? tenantId, + UserKey userId, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + throw new NotImplementedException(); + + //var lifecycle = await _lifecycle.GetAsync(tenantId, userId, ct); + //return lifecycle is not null && !lifecycle.IsDeleted; + } +} + 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..d64f8988 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs new file mode 100644 index 00000000..4279660d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class ChangeUserIdentifierCommand : IAccessCommand +{ + private readonly Func _execute; + + public ChangeUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs new file mode 100644 index 00000000..d6299789 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class ChangeUserStatusCommand : IAccessCommand + { + private readonly Func _execute; + + public ChangeUserStatusCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs new file mode 100644 index 00000000..85720a7b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class CreateUserCommand : IAccessCommand + { + private readonly Func _execute; + + public CreateUserCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs new file mode 100644 index 00000000..96039d38 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class DeleteUserCommand : IAccessCommand + { + private readonly Func _execute; + + public DeleteUserCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs new file mode 100644 index 00000000..12f6b722 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class DeleteUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public DeleteUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs new file mode 100644 index 00000000..068a1cd0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class GetCurrentUserProfileCommand : IAccessCommand +{ + private readonly Func> _execute; + + public GetCurrentUserProfileCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs new file mode 100644 index 00000000..ddca8f73 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class GetUserIdentifiersCommand : IAccessCommand + { + private readonly Func> _execute; + + public GetUserIdentifiersCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs new file mode 100644 index 00000000..44d4bd40 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class GetUserProfileAdminCommand : IAccessCommand +{ + private readonly Func> _execute; + + public GetUserProfileAdminCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs new file mode 100644 index 00000000..12b6cb87 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UpdateCurrentUserProfileCommand : IAccessCommand +{ + private readonly Func _execute; + + public UpdateCurrentUserProfileCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs new file mode 100644 index 00000000..b9d550c3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UpdateUserProfileAdminCommand : IAccessCommand +{ + private readonly Func _execute; + + public UpdateUserProfileAdminCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs new file mode 100644 index 00000000..8e4f8985 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class VerifyUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public VerifyUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs new file mode 100644 index 00000000..0019d7de --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference.Domain; + +public sealed record class ReferenceUserProfile +{ + public UserKey UserKey { get; init; } = default!; + + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? DisplayName { get; set; } + public string? Email { get; init; } + public string? Phone { get; init; } + + public UserStatus Status { get; set; } = UserStatus.Active; + + public bool IsDeleted { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs new file mode 100644 index 00000000..292d1819 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public sealed record UserIdentifierRecord + { + public UserKey UserKey { get; init; } + + public UserIdentifierType Type { get; init; } // Email, Phone, Username + public string Value { get; init; } = default!; + + public bool IsPrimary { get; init; } + public bool IsVerified { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; init; } + public DateTimeOffset? DeletedAt { get; init; } + } + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs new file mode 100644 index 00000000..94019c43 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs @@ -0,0 +1,96 @@ +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +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 DefaultUserLifecycleEndpointHandler : IUserLifecycleEndpointHandler + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUserLifecycleService _lifecycle; + + public DefaultUserLifecycleEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserLifecycleService lifecycle) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _lifecycle = lifecycle; + } + + public async Task CreateAsync(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.Create, + resource: "users" + ); + + var result = await _lifecycle.CreateAsync(accessContext, request, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + public async Task ChangeStatusAsync(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.ChangeStatus, + resource: "users", + resourceId: request.UserKey.Value + ); + + var result = await _lifecycle.ChangeStatusAsync(accessContext, request, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + public async Task DeleteAsync(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.Delete, + resource: "users", + resourceId: request.UserKey.Value, + attributes: new Dictionary + { + ["deleteMode"] = request.Mode + } + ); + + var result = await _lifecycle.DeleteAsync(accessContext, request, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs new file mode 100644 index 00000000..dd7816cc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs @@ -0,0 +1,61 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +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 DefaultUserProfileAdminEndpointHandler : IUserProfileAdminEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUserProfileAdminService _profiles; + + public DefaultUserProfileAdminEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserProfileAdminService profiles) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _profiles = profiles; + } + + public async Task GetAsync(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 result = await _profiles.GetAsync(accessContext, userKey, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task UpdateAsync(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 _profiles.UpdateAsync(accessContext, userKey, request, ctx.RequestAborted); + return Results.NoContent(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs new file mode 100644 index 00000000..92093274 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs @@ -0,0 +1,62 @@ +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +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 DefaultUserProfileEndpointHandler : IUserProfileEndpointHandler + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUAuthUserProfileService _profiles; + + public DefaultUserProfileEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUAuthUserProfileService profiles) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _profiles = profiles; + } + + public async Task GetAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated || flow.UserKey is null) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.GetSelf, + resource: "users", + resourceId: flow.UserKey.Value + ); + + var result = await _profiles.GetCurrentAsync(accessContext, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task UpdateAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated || flow.UserKey is null) + 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 _profiles.UpdateCurrentAsync(accessContext, request, ctx.RequestAborted); + return Results.NoContent(); + } + + } +} 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..f2f1edd4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Users.Reference; +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.TryAddScoped(); + + return services; + } + + private sealed class UsersReferenceMarker; +} 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..e1184ec1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public static class UserIdentifierMapper + { + public static UserIdentifierDto ToDto(UserIdentifierRecord record) + => new() + { + Type = record.Type, + Value = record.Value, + IsPrimary = record.IsPrimary, + IsVerified = record.IsVerified, + CreatedAt = record.CreatedAt, + VerifiedAt = record.VerifiedAt + }; + } +} 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..594c63d1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal static class UserProfileMapper +{ + public static UserProfileDto ToDto(ReferenceUserProfile profile) + => new() + { + UserKey = profile.UserKey.ToString(), + FirstName = profile.FirstName, + LastName = profile.LastName, + DisplayName = profile.DisplayName, + Email = profile.Email, + Phone = profile.Phone, + Status = profile.Status, + Metadata = profile.Metadata + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs new file mode 100644 index 00000000..64b92e1c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs @@ -0,0 +1,126 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class DefaultUserIdentifierService : IUserIdentifierService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserIdentifierStore _store; + private readonly UAuthServerOptions _serverOptions; + private readonly IClock _clock; + + public DefaultUserIdentifierService(IAccessOrchestrator accessOrchestrator, IUserIdentifierStore store, IOptions serverOptions, IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _store = store; + _serverOptions = serverOptions.Value; + _clock = clock; + } + + public async Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var cmd = new GetUserIdentifiersCommand( + async innerCt => + { + var records = await _store.GetAllAsync( + context.ResourceTenantId, + targetUserKey, + innerCt); + + var dtos = records + .Where(r => r.DeletedAt is null) + .Select(UserIdentifierMapper.ToDto) + .ToArray(); + + return new GetUserIdentifiersResult + { + Identifiers = dtos + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task ChangeAsync(AccessContext context, UserKey targetUserKey, ChangeUserIdentifierRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var record = new UserIdentifierRecord + { + Type = request.Type, + Value = request.NewValue, + IsVerified = false, + CreatedAt = _clock.UtcNow, + VerifiedAt = null, + DeletedAt = null + }; + + var cmd = new ChangeUserIdentifierCommand( + async innerCt => + { + var exists = await _store.ExistsAsync(context.ResourceTenantId, request.Type, request.NewValue, innerCt); + + if (exists) + throw new InvalidOperationException("identifier_already_exists"); + + await _store.SetAsync(context.ResourceTenantId, targetUserKey, record, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + + return IdentifierChangeResult.Success(); + } + + public async Task VerifyAsync(AccessContext context, UserKey targetUserKey, VerifyUserIdentifierRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var cmd = new VerifyUserIdentifierCommand( + async innerCt => + { + await _store.MarkVerifiedAsync(context.ResourceTenantId, targetUserKey, request.Type, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + + return IdentifierVerificationResult.Success(); + } + + public async Task DeleteAsync(AccessContext context, UserKey targetUserKey, DeleteUserIdentifierRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var cmd = new DeleteUserIdentifierCommand( + async innerCt => + { + var identifiers = await _store.GetByTypeAsync(context.ResourceTenantId, targetUserKey, request.Type, innerCt); + + var activeCount = identifiers.Count(i => i.DeletedAt is null); + + if (activeCount <= 1 && request.Type == UserIdentifierType.Username) + throw new InvalidOperationException("last_username_cannot_be_deleted"); + + await _store.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Type, request.Value, request.Mode, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + + return IdentifierDeleteResult.Success(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs new file mode 100644 index 00000000..325fd0c0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs @@ -0,0 +1,184 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class DefaultUserLifecycleService : IUserLifecycleService + { + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserStore _users; + private readonly IUserProfileStore _profiles; + private readonly IUserLifecycleStore _userLifecycleStore; + private readonly IUserCredentialsService _credentials; + private readonly IUserCredentialsInternalService _credentialsInternal; + private readonly IUserIdentifierService _identifierService; + private readonly ISessionService _sessionService; + private readonly IAuthContextFactory _authContextFactory; + private readonly IClock _clock; + + public DefaultUserLifecycleService( + IAccessOrchestrator accessOrchestrator, + IUserStore users, + IUserProfileStore profiles, + IUserLifecycleStore userLifecycleStore, + IUserCredentialsService credentials, + IUserCredentialsInternalService credentialsInternal, + IUserIdentifierService identifierService, + ISessionService sessionService, + IAuthContextFactory authContextFactory, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _users = users; + _profiles = profiles; + _userLifecycleStore = userLifecycleStore; + _credentials = credentials; + _credentialsInternal = credentialsInternal; + _identifierService = identifierService; + _sessionService = sessionService; + _authContextFactory = authContextFactory; + _clock = clock; + } + + public async Task CreateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(request.Identifier)) + return UserCreateResult.Failed("identifier_required"); + + var policies = Array.Empty(); + var userKey = UserKey.New(); + var now = _clock.UtcNow; + + var cmd = new CreateUserCommand( + async innerCt => + { + var existing = await _users.FindByLoginAsync(context.ResourceTenantId, request.Identifier, innerCt); + + if (existing is not null) + throw new InvalidOperationException("user_already_exists"); + + var profile = new ReferenceUserProfile + { + UserKey = userKey, + Email = request.Identifier, + DisplayName = request.DisplayName, + Status = UserStatus.Active, + IsDeleted = false, + CreatedAt = now, + UpdatedAt = now, + DeletedAt = null, + FirstName = request.Profile?.FirstName, + LastName = request.Profile?.LastName, + }; + + await _userLifecycleStore.CreateAsync(context.ResourceTenantId, profile, innerCt); + await _profiles.CreateAsync(context.ResourceTenantId, profile, innerCt); + + if (!string.IsNullOrWhiteSpace(request.Password)) + { + await _credentials.AddAsync( + new AccessContext + { + ActorUserKey = context.ActorUserKey, + ActorTenantId = context.ActorTenantId, + IsAuthenticated = context.IsAuthenticated, + + Resource = "credentials", + ResourceId = userKey.Value, + ResourceTenantId = context.ResourceTenantId, + + Action = UAuthActions.Credentials.Add + }, + new AddCredentialRequest + { + Type = CredentialType.Password, + Secret = request.Password + }, + innerCt); + + } + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + return UserCreateResult.Success(userKey); + } + + public async Task ChangeStatusAsync(AccessContext context, ChangeUserStatusRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + UserStatus oldStatus = default; + + var cmd = new ChangeUserStatusCommand( + async innerCt => + { + var profile = await _profiles.GetAsync(context.ResourceTenantId, request.UserKey, innerCt); + + if (profile is null) + throw new InvalidOperationException("user_not_found"); + + if (profile.Status == request.NewStatus) + return; + + oldStatus = profile.Status; + //await _profiles.SetStatusAsync(context.ResourceTenantId, request.UserKey, request.NewStatus, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + return UserStatusChangeResult.Success(oldStatus, request.NewStatus); + } + + public async Task DeleteAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var cmd = new DeleteUserCommand( + async innerCt => + { + var user = await _users.FindByIdAsync(context.ResourceTenantId, request.UserKey, innerCt); + + if (user is null) + throw new InvalidOperationException("user_not_found"); + + var authContext = _authContextFactory.Create(); + if (request.Mode == DeleteMode.Soft) + { + if (user.IsDeleted) + return; + + await _userLifecycleStore.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Soft, _clock.UtcNow, innerCt); + await _sessionService.RevokeAllAsync(authContext, request.UserKey,innerCt); + return; + } + + // Hard delete + if (!user.IsDeleted) + { + await _userLifecycleStore.DeleteAsync(context.ResourceTenantId,request.UserKey, DeleteMode.Soft, _clock.UtcNow, innerCt); + } + + await _sessionService.RevokeAllAsync(authContext, request.UserKey, innerCt); + await _credentialsInternal.DeleteInternalAsync(context.ResourceTenantId, request.UserKey, innerCt); + await _profiles.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Hard, innerCt); + await _userLifecycleStore.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Hard, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + return UserDeleteResult.Success(request.Mode); + } + + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs new file mode 100644 index 00000000..c01fb80f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs @@ -0,0 +1,54 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class DefaultUserProfileAdminService : IUserProfileAdminService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserProfileStore _profiles; + + public DefaultUserProfileAdminService(IAccessOrchestrator accessOrchestrator, IUserProfileStore profiles) + { + _accessOrchestrator = accessOrchestrator; + _profiles = profiles; + } + + public async Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var cmd = new GetUserProfileAdminCommand( + async innerCt => + { + var profile = await _profiles.GetAsync(context.ResourceTenantId, targetUserKey, innerCt); + + if (profile is null) + throw new InvalidOperationException("user_profile_not_found"); + + return UserProfileMapper.ToDto(profile); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task UpdateAsync(AccessContext context, UserKey targetUserKey, UpdateProfileRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var cmd = new UpdateUserProfileAdminCommand( + async innerCt => + { + await _profiles.UpdateAsync(context.ResourceTenantId, targetUserKey, request, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs new file mode 100644 index 00000000..af43cbb7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs @@ -0,0 +1,60 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class DefaultUserProfileService : IUAuthUserProfileService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserProfileStore _profiles; + + public DefaultUserProfileService(IAccessOrchestrator accessOrchestrator, IUserProfileStore profiles) + { + _accessOrchestrator = accessOrchestrator; + _profiles = profiles; + } + + public async Task GetCurrentAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var cmd = new GetCurrentUserProfileCommand( + async innerCt => + { + if (context.ActorUserKey is null) + throw new UnauthorizedAccessException(); + + var profile = await _profiles.GetAsync(context.ResourceTenantId, (UserKey)context.ActorUserKey, innerCt); + + if (profile is null) + throw new InvalidOperationException("user_profile_not_found"); + + return UserProfileMapper.ToDto(profile); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task UpdateCurrentAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = Array.Empty(); + + var cmd = new UpdateCurrentUserProfileCommand( + async innerCt => + { + if (context.ActorUserKey is null) + throw new UnauthorizedAccessException(); + + await _profiles.UpdateAsync(context.ResourceTenantId, (UserKey)context.ActorUserKey, request, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs new file mode 100644 index 00000000..d70e1a54 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUserIdentifierService + { + Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); + Task ChangeAsync(AccessContext context, UserKey targetUserKey, ChangeUserIdentifierRequest request, CancellationToken ct = default); + Task VerifyAsync(AccessContext context, UserKey targetUserKey, VerifyUserIdentifierRequest request, CancellationToken ct = default); + Task DeleteAsync(AccessContext context, UserKey targetUserKey, DeleteUserIdentifierRequest request, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs new file mode 100644 index 00000000..c416e87c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserLifecycleService +{ + Task CreateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); + Task ChangeStatusAsync(AccessContext context, ChangeUserStatusRequest request, CancellationToken ct = default); + Task DeleteAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs new file mode 100644 index 00000000..5b8f402d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUserProfileAdminService + { + Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); + Task UpdateAsync(AccessContext context, UserKey targetUserKey, UpdateProfileRequest request, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs new file mode 100644 index 00000000..1c968332 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUAuthUserProfileService + { + Task GetCurrentAsync(AccessContext context, CancellationToken ct = default); + Task UpdateCurrentAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); + } +} 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..f07c2dca --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUserIdentifierStore + { + Task> GetAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task> GetByTypeAsync(string? tenantId, UserKey userKey, UserIdentifierType type, CancellationToken ct = default); + Task SetAsync(string? tenantId, UserKey userKey, UserIdentifierRecord record, CancellationToken ct = default); + Task MarkVerifiedAsync(string? tenantId, UserKey userKey, UserIdentifierType type, DateTimeOffset verifiedAt, CancellationToken ct = default); + Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, DeleteMode mode, CancellationToken ct = default); + } +} 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..15f279e3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUserLifecycleStore + { + Task CreateAsync(string? tenantId, ReferenceUserProfile user, CancellationToken ct = default); + Task UpdateStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset at, CancellationToken ct = default); + } +} 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..c2f300da --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserProfileStore +{ + // TODO: Do CreateAsync internal with initializer service + Task CreateAsync(string? tenantId, ReferenceUserProfile profile, CancellationToken ct = default); + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task UpdateAsync(string? tenantId, UserKey userKey, UpdateProfileRequest request, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, 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..b4a93486 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users +{ + public interface IUser + { + TUserId UserId { get; } + bool IsActive { get; } + } +} 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..4c1e0cbd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityEvents +{ + Task OnUserActivatedAsync(TUserId userId); + Task OnUserDeactivatedAsync(TUserId userId); + Task OnSecurityInvalidatedAsync(TUserId userId); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs new file mode 100644 index 00000000..f6b6547d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityState +{ + long SecurityVersion { get; } + bool IsLocked { get; } + bool RequiresReauthentication { get; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs new file mode 100644 index 00000000..f001bef8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users +{ + public interface IUserSecurityStateProvider + { + Task GetAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs new file mode 100644 index 00000000..1534c020 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users +{ + public interface IUserStore + { + /// + /// Finds a user by its application-level user id. + /// Returns null if the user does not exist or is deleted. + /// + Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + + /// + /// Finds a user by a login identifier (username, email, etc). + /// Used during login discovery phase. + /// + Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default); + + /// + /// Checks whether a user exists. + /// Fast-path helper for authorities. + /// + Task ExistsAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + } +} 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..1f3e2def --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj @@ -0,0 +1,15 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs index 20da8c8b..78922083 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs @@ -1,7 +1,9 @@ -using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Tests.Unit.Fake; using Microsoft.Extensions.Options; @@ -10,82 +12,79 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Client { public sealed class BlazorServerSessionCoordinatorTests { - [Fact] - public async Task StartAsync_MarksStarted_AndAutomaticRefresh() - { - var diagnostics = new UAuthClientDiagnostics(); - var client = new FakeUAuthClient(RefreshOutcome.NoOp); - var nav = new TestNavigationManager(); + //[Fact] + //public async Task StartAsync_MarksStarted_AndAutomaticRefresh() + //{ + // var diagnostics = new UAuthClientDiagnostics(); + // var client = new FakeFlowClient(RefreshOutcome.NoOp); + // var nav = new TestNavigationManager(); - var options = Options.Create(new UAuthClientOptions - { - Refresh = { Interval = TimeSpan.FromMilliseconds(10) } - }); + // var options = Options.Create(new UAuthClientOptions + // { + // Refresh = { Interval = TimeSpan.FromMilliseconds(10) } + // }); - var coordinator = new BlazorServerSessionCoordinator( - client, - nav, - options, - diagnostics); + // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), + // nav, + // options, + // diagnostics); - await coordinator.StartAsync(); - await Task.Delay(30); - await coordinator.StopAsync(); + // await coordinator.StartAsync(); + // await Task.Delay(30); + // await coordinator.StopAsync(); - Assert.Equal(1, diagnostics.StartCount); - Assert.True(diagnostics.AutomaticRefreshCount >= 1); - } + // Assert.Equal(1, diagnostics.StartCount); + // Assert.True(diagnostics.AutomaticRefreshCount >= 1); + //} - [Fact] - public async Task ReauthRequired_ShouldTerminateAndNavigate() - { - var diagnostics = new UAuthClientDiagnostics(); - var client = new FakeUAuthClient(RefreshOutcome.ReauthRequired); - var nav = new TestNavigationManager(); + //[Fact] + //public async Task ReauthRequired_ShouldTerminateAndNavigate() + //{ + // var diagnostics = new UAuthClientDiagnostics(); + // var client = new FakeFlowClient(RefreshOutcome.ReauthRequired); + // var nav = new TestNavigationManager(); - var options = Options.Create(new UAuthClientOptions - { - Refresh = { Interval = TimeSpan.FromMilliseconds(5) }, - Reauth = - { - Behavior = ReauthBehavior.RedirectToLogin, - LoginPath = "/login" - } - }); + // var options = Options.Create(new UAuthClientOptions + // { + // Refresh = { Interval = TimeSpan.FromMilliseconds(5) }, + // Reauth = + // { + // Behavior = ReauthBehavior.RedirectToLogin, + // LoginPath = "/login" + // } + // }); - var coordinator = new BlazorServerSessionCoordinator( - client, - nav, - options, - diagnostics); + // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), + // nav, + // options, + // diagnostics); - await coordinator.StartAsync(); - await Task.Delay(20); + // await coordinator.StartAsync(); + // await Task.Delay(20); - Assert.True(diagnostics.IsTerminated); - Assert.Equal(CoordinatorTerminationReason.ReauthRequired, diagnostics.TerminationReason); - Assert.Equal("/login", nav.LastNavigatedTo); - } + // Assert.True(diagnostics.IsTerminated); + // Assert.Equal(CoordinatorTerminationReason.ReauthRequired, diagnostics.TerminationReason); + // Assert.Equal("/login", nav.LastNavigatedTo); + //} - [Fact] - public async Task StopAsync_ShouldMarkStopped() - { - var diagnostics = new UAuthClientDiagnostics(); - var client = new FakeUAuthClient(); - var nav = new TestNavigationManager(); + //[Fact] + //public async Task StopAsync_ShouldMarkStopped() + //{ + // var diagnostics = new UAuthClientDiagnostics(); + // var client = new FakeFlowClient(); + // var nav = new TestNavigationManager(); - var options = Options.Create(new UAuthClientOptions()); + // var options = Options.Create(new UAuthClientOptions()); - var coordinator = new BlazorServerSessionCoordinator( - client, - nav, - options, - diagnostics); + // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), + // nav, + // options, + // diagnostics); - await coordinator.StartAsync(); - await coordinator.StopAsync(); + // await coordinator.StartAsync(); + // await coordinator.StopAsync(); - Assert.Equal(1, diagnostics.StopCount); - } + // Assert.Equal(1, diagnostics.StopCount); + //} } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 145cd305..6e11feeb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -18,17 +18,28 @@ + + + + - + + + + + + + + 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..a5d74ae8 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs @@ -0,0 +1,102 @@ +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.ToString(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.ToString(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.ToString(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.ToString(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.ToString(id); + var parsed = converter.FromString(str); + + Assert.Equal(id, parsed); + } + + [Fact] + public void Double_UserId_Should_Throw() + { + var converter = new UAuthUserIdConverter(); + + Assert.ThrowsAny(() => converter.ToString(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.ToString(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/Fake/FakeUAuthClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs similarity index 92% rename from tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs rename to tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index 8b2dcf72..5b6b633e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeUAuthClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -1,16 +1,17 @@ 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 System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit { - internal sealed class FakeUAuthClient : IUAuthClient + internal sealed class FakeFlowClient : IFlowClient { private readonly Queue _outcomes; - public FakeUAuthClient(params RefreshOutcome[] outcomes) + public FakeFlowClient(params RefreshOutcome[] outcomes) { _outcomes = new Queue(outcomes); } From 795cf3616d8b440aea81e9896742b2c111178088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:51:18 +0300 Subject: [PATCH 25/50] Add architectural principles document for UltimateAuth This document outlines the architectural principles, scope, and responsibilities of the UltimateAuth framework, emphasizing the separation of authentication and authorization, session management, and security invariants. --- .ultimateauth/architecture.md | 834 ++++++++++++++++++++++++++++++++++ 1 file changed, 834 insertions(+) create mode 100644 .ultimateauth/architecture.md 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. From 8b0235d47baf4421d586f82bca13aef8efef2c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:56:22 +0300 Subject: [PATCH 26/50] Add AI guardrails for UltimateAuth codebase This document outlines mandatory guidelines for AI-assisted tools in the UltimateAuth codebase, emphasizing security and integrity rules. --- .ultimateauth/ai-guardrails.md | 118 +++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 .ultimateauth/ai-guardrails.md 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. From b3ddaa0f538c53c99035e568da0a561ffaa825de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 1 Feb 2026 01:12:09 +0300 Subject: [PATCH 27/50] Preparation Of First Release (Part 7/7) (#15) * Add New User Domains * Fix Login Flow With UserStatus Check Support & Added IUserApplicationService * Fix Policies * Added User Status Change * Added Identifier Client * Added Authorizaton Client * Added Credentials Client --- .../Program.cs | 2 +- .../Components/Pages/Home.razor | 4 +- .../Components/Pages/Home.razor.cs | 4 +- .../Program.cs | 23 +- .../CodeBeam.UltimateAuth.Client.csproj | 1 + ...teAuthClientServiceCollectionExtensions.cs | 8 +- .../Services/DefaultAuthorizationClient.cs | 65 +++ .../Services/DefaultCredentialClient.cs | 103 +++++ .../Services/DefaultUserClient.cs | 19 +- .../Services/DefaultUserIdentifierClient.cs | 120 +++++ .../Services/IAuthorizationClient.cs | 19 + .../Services/ICredentialClient.cs | 24 + .../Services/IUAuthClient.cs | 3 + .../Services/IUserClient.cs | 7 +- .../Services/IUserIdentifierClient.cs | 25 ++ .../Services/UAuthClient.cs | 8 +- .../Infrastructure/ISeedContributor.cs | 16 + .../User/IUserRuntimeStateProvider.cs | 12 + .../Contracts/Authority/AccessContext.cs | 8 + .../Contracts/Common/PagedResult.cs | 14 + .../Domain/AuthFlowType.cs | 3 +- .../Domain/User/UserRuntimeRecord.cs | 9 + ...UltimateAuthServiceCollectionExtensions.cs | 1 + .../Infrastructure/SeedRunner.cs | 26 ++ .../Defaults/UAuthActions.cs | 51 ++- .../IAuthorizationEndpointHandler.cs | 3 +- .../ICredentialEndpointHandler.cs | 30 +- .../Abstractions/IUserEndpointHandler.cs | 34 ++ .../IUserLifecycleEndpointHandler.cs | 11 - .../IUserProfileAdminEndpointHandler.cs | 11 - .../IUserProfileEndpointHandler.cs | 10 - .../Endpoints/UAuthEndpointRegistrar.cs | 155 +++++-- .../UAuthServerServiceCollectionExtensions.cs | 48 +- .../Login/DefaultLoginOrchestrator.cs | 9 +- .../Options/UAuthServerOptions.cs | 11 +- .../AuthorizationInMemoryExtensions.cs | 9 +- .../InMemoryAuthorizationSeedContributor.cs | 26 ++ .../InMemoryAuthorizationSeeder.cs | 23 - .../Stores/InMemoryUserRoleStore.cs | 60 +-- .../DefaultAuthorizationEndpointHandler.cs | 29 +- .../Dtos/CredentialSecurityState.cs | 6 +- .../Dtos/CredentialSecurityStatus.cs | 4 +- .../Request/BeginCredentialResetRequest.cs | 7 + .../Request/CompleteCredentialResetRequest.cs | 8 + .../InMemoryCredentialSeedContributor.cs | 45 ++ .../InMemoryCredentialStore.cs | 66 +-- .../UltimateAuthDefaultsInMemoryExtensions.cs | 10 +- .../Commands/ActivateCredentialCommand.cs | 6 +- .../Commands/AddCredentialCommand.cs | 6 +- .../Commands/BeginCredentialResetCommand.cs | 18 + .../Commands/ChangeCredentialCommand.cs | 9 +- .../CompleteCredentialResetCommand.cs | 16 + .../Commands/DeleteCredentialCommand.cs | 10 +- .../Commands/GetAllCredentialsCommand.cs | 10 +- .../Commands/RevokeCredentialCommand.cs | 10 +- .../Commands/SetInitialCredentialCommand.cs | 10 +- .../DefaultCredentialEndpointHandler.cs | 375 +++++++++++----- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../PasswordUserLifecycleIntegration.cs | 47 ++ .../Services/DefaultUserCredentialsService.cs | 47 +- .../Services/IUserCredentialsService.cs | 5 +- .../AssemblyVisibility.cs | 1 + .../Defaults/DefaultPolicySet.cs | 25 +- .../Policies/RequireActiveUserPolicy.cs | 46 ++ .../Policies/RequireAdminPolicy.cs | 2 +- .../Policies/RequireSelfOrAdminPolicy.cs | 2 +- .../Policies/RequireSelfPolicy.cs | 2 +- .../Policies/RequireSystemPolicy.cs | 15 + .../Dtos/UserStatus.cs | 10 +- .../{UserProfileDto.cs => UserViewDto.cs} | 13 +- .../Requests/AddUserIdentifierRequest.cs | 9 + ...est.cs => ChangeUserStatusAdminRequest.cs} | 2 +- .../Requests/ChangeUserStatusSelfRequest.cs | 7 + .../Requests/CreateUserRequest.cs | 40 +- .../Requests/DeleteUserRequest.cs | 1 - .../SerPrimaryUserIdentifierRequest.cs | 8 + .../UnsetPrimaryUserIdentifierRequest.cs | 8 + .../Requests/UpdateProfileRequest.cs | 8 +- .../Requests/UpdateUserIdentifierRequest.cs | 9 + .../Requests/VerifyUserIdentifierRequest.cs | 2 +- .../UltimateAuthUsersInMemoryExtensions.cs | 14 +- .../InMemoryUserSeedContributor.cs | 73 +++ .../Stores/InMemoryUserIdentifierStore.cs | 190 +++++--- .../Stores/InMemoryUserLifecycleStore.cs | 168 +++---- .../Stores/InMemoryUserProfileStore.cs | 143 +++--- .../Stores/InMemoryUserStore.cs | 86 ---- .../Commands/AddUserIdentifierCommand.cs | 16 + .../Commands/CreateUserCommand.cs | 9 +- .../Commands/GetCurrentUserProfileCommand.cs | 18 - .../Commands/GetMeCommand.cs | 18 + .../Commands/GetUserIdentifierCommand.cs | 22 + .../Commands/GetUserIdentifiersCommand.cs | 8 +- .../Commands/GetUserProfileAdminCommand.cs | 18 - .../Commands/GetUserProfileCommand.cs | 18 + .../SetPrimaryUserIdentifierCommand.cs | 16 + .../UnsetPrimaryUserIdentifierCommand.cs | 16 + .../UpdateCurrentUserProfileCommand.cs | 17 - .../Commands/UpdateUserIdentifierCommand.cs | 16 + .../Commands/UpdateUserProfileCommand.cs | 15 + .../Commands/UserIdentifierExistsCommand.cs | 21 + .../Contracts/UserLifecycleQuery.cs | 13 + .../Contracts/UserProfileQuery.cs | 10 + .../Contracts/UserProfileUpdate.cs | 17 + .../Domain/UserIdentifier.cs | 24 + .../Domain/UserIdentifierRecord.cs | 21 - ...ferenceUserProfile.cs => UserLifecycle.cs} | 16 +- .../Domain/UserProfile.cs | 31 ++ .../Endpoints/DefaultUserEndpointHandler.cs | 419 ++++++++++++++++++ .../DefaultUserLifecycleEndpointHandler.cs | 96 ---- .../DefaultUserProfileAdminEndpointHandler.cs | 61 --- .../DefaultUserProfileEndpointHandler.cs | 62 --- .../Extensions/ServiceCollectonExtensions.cs | 16 +- .../Mapping/UserIdentifierMapper.cs | 2 +- .../Mapping/UserProfileMapper.cs | 26 +- .../Services/DefaultUserIdentifierService.cs | 126 ------ .../Services/DefaultUserLifecycleService.cs | 184 -------- .../DefaultUserProfileAdminService.cs | 54 --- .../Services/DefaultUserProfileService.cs | 60 --- .../Services/IUserApplicationService.cs | 37 ++ .../Services/IUserIdentifierService.cs | 14 - .../Services/IUserLifecycleService.cs | 12 - .../Services/IUserProfileAdminService.cs | 12 - .../Services/IUserProfileService.cs | 11 - .../Services/UserApplicationService.cs | 377 ++++++++++++++++ .../Stores/IUserIdentifierStore.cs | 32 +- .../Stores/IUserLifecycleStore.cs | 17 +- .../Stores/IUserProfileStore.cs | 18 +- .../Stores/UserRuntimeStore.cs | 32 ++ .../Abstractions/IUserLifecycleIntegration.cs | 15 + .../Abstractions/IUserStore.cs | 25 -- .../CodeBeam.UltimateAuth.Users.csproj | 1 + .../Policies/ActionTextTests.cs | 34 ++ 132 files changed, 3057 insertions(+), 1691 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/{UserProfileDto.cs => UserViewDto.cs} (62%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/{ChangeUserStatusRequest.cs => ChangeUserStatusAdminRequest.cs} (80%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs rename src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/{ReferenceUserProfile.cs => UserLifecycle.cs} (51%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 53d75b8d..4ea4eb47 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -90,7 +90,7 @@ { scope.ServiceProvider.GetRequiredService(); scope.ServiceProvider.GetRequiredService(); - scope.ServiceProvider.GetRequiredService>(); + scope.ServiceProvider.GetRequiredService(); var seeder = scope.ServiceProvider.GetService(); //if (seeder is not null) 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 index 6919b0a8..ebf7a93f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -31,8 +31,8 @@ Welcome to UltimateAuth! - - + + Login 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 index 80c99fe9..6b5ce18b 100644 --- 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 @@ -86,12 +86,12 @@ private async Task HandleGetMe() private async Task ChangeUserInactive() { - ChangeUserStatusRequest request = new ChangeUserStatusRequest + ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest { UserKey = UserKey.FromString("user"), NewStatus = UserStatus.Disabled }; - var result = await UAuth.Users.ChangeStatusAsync(request); + var result = await UAuth.Users.ChangeStatusAdminAsync(request); if (result.Ok) { Snackbar.Add($"User is disabled.", Severity.Info); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index cff25580..1d022200 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -2,8 +2,11 @@ using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; @@ -13,6 +16,7 @@ using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users.InMemory; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference; using CodeBeam.UltimateAuth.Users.Reference.Extensions; @@ -96,17 +100,6 @@ var app = builder.Build(); -using (var scope = app.Services.CreateScope()) -{ - scope.ServiceProvider.GetRequiredService(); - //scope.ServiceProvider.GetRequiredService(); - //scope.ServiceProvider.GetRequiredService>(); - - var seeder = scope.ServiceProvider.GetService(); - //if (seeder is not null) - // await seeder.SeedAsync(); -} - // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -118,6 +111,14 @@ { app.MapOpenApi(); app.MapScalarApiReference(); + using var scope = app.Services.CreateScope(); + //scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService>(); + var seedRunner = scope.ServiceProvider.GetRequiredService(); + + await seedRunner.RunAsync(tenantId: null); } app.UseHttpsRedirection(); diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index 5ead5c54..928068a8 100644 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -29,6 +29,7 @@ + diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs index fab6aae4..db0e6428 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs @@ -97,10 +97,12 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol o.Refresh.Interval ??= TimeSpan.FromMinutes(5); }); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(sp => { diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs new file mode 100644 index 00000000..1985a95f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs @@ -0,0 +1,65 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +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 DefaultAuthorizationClient : IAuthorizationClient + { + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public DefaultAuthorizationClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + public async Task> CheckAsync(AuthorizationCheckRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/check"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetMyRolesAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/users/me/roles/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetUserRolesAsync(UserKey userKey) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task AssignRoleAsync(UserKey userKey, string role) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/post"); + var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + { + Role = role + }); + + return UAuthResultMapper.FromStatus(raw); + } + + public async Task RemoveRoleAsync(UserKey userKey, string role) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/delete"); + + var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + { + Role = role + }); + + return UAuthResultMapper.FromStatus(raw); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs new file mode 100644 index 00000000..0d00f49b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs @@ -0,0 +1,103 @@ +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 DefaultUserCredentialClient : ICredentialClient + { + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public DefaultUserCredentialClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + private string Url(string path) => UAuthUrlBuilder.Combine(_options.Endpoints.Authority, path); + + public async Task> GetMyAsync() + { + var raw = await _request.SendFormForJsonAsync(Url("/credentials/get")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> AddMyAsync(AddCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url("/credentials/add"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/change"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/revoke"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/begin"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/complete"), request); + return UAuthResultMapper.FromStatus(raw); + } + + + public async Task> GetUserAsync(UserKey userKey) + { + var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey}/credentials/get")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/add"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/revoke"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task ActivateUserAsync(UserKey userKey, CredentialType type) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/activate")); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/begin"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/complete"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteUserAsync(UserKey userKey, CredentialType type) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/delete")); + return UAuthResultMapper.FromStatus(raw); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs index 62818e86..9616a1b7 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs @@ -18,11 +18,11 @@ public DefaultUserClient(IUAuthRequestClient request, IOptions> GetMeAsync() + public async Task> GetMeAsync() { var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/get"); var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) @@ -39,9 +39,16 @@ public async Task> CreateAsync(CreateUserRequest r return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeStatusAsync(ChangeUserStatusRequest request) + public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/status"); + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/status"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{request.UserKey.Value}/status"); var raw = await _request.SendJsonAsync(url, request); return UAuthResultMapper.FromJson(raw); } @@ -53,11 +60,11 @@ public async Task> DeleteAsync(DeleteUserRequest r return UAuthResultMapper.FromJson(raw); } - public async Task> GetProfileAsync(UserKey userKey) + public async Task> GetProfileAsync(UserKey userKey) { var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/get"); var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs new file mode 100644 index 00000000..50a0a6cc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs @@ -0,0 +1,120 @@ +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 +{ + public class DefaultUserIdentifierClient : IUserIdentifierClient + { + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public DefaultUserIdentifierClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + public async Task>> GetMyIdentifiersAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/identifiers/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task AddSelfAsync(AddUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/add"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/set-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/unset-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/verify"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/delete"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task>> GetUserIdentifiersAsync(UserKey userKey) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey.Value}/identifiers/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/add"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/set-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/unset-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/verify"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/delete"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs new file mode 100644 index 00000000..75400057 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs @@ -0,0 +1,19 @@ +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(); + + Task> GetUserRolesAsync(UserKey userKey); + + Task AssignRoleAsync(UserKey userKey, string role); + + Task RemoveRoleAsync(UserKey userKey, string role); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs new file mode 100644 index 00000000..468dbcce --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + public interface ICredentialClient + { + Task> GetMyAsync(); + Task> AddMyAsync(AddCredentialRequest request); + Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request); + Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request); + Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request); + Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request); + + Task> GetUserAsync(UserKey userKey); + Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); + Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request); + Task ActivateUserAsync(UserKey userKey, CredentialType type); + Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request); + Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request); + Task DeleteUserAsync(UserKey userKey, CredentialType type); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs index e6bddb43..0a97c461 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -6,5 +6,8 @@ public interface IUAuthClient { IFlowClient Flows { get; } IUserClient Users { get; } + IUserIdentifierClient Identifiers { get; } + ICredentialClient Credentials { get; } + IAuthorizationClient Authorization { get; } } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs index 79d6bd90..a59a4cdb 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs @@ -7,13 +7,14 @@ namespace CodeBeam.UltimateAuth.Client.Services public interface IUserClient { Task> CreateAsync(CreateUserRequest request); - Task> ChangeStatusAsync(ChangeUserStatusRequest request); + Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); + Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request); Task> DeleteAsync(DeleteUserRequest request); - Task> GetMeAsync(); + Task> GetMeAsync(); Task UpdateMeAsync(UpdateProfileRequest request); - Task> GetProfileAsync(UserKey userKey); + Task> GetProfileAsync(UserKey userKey); Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs new file mode 100644 index 00000000..5d408165 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + public interface IUserIdentifierClient + { + Task>> GetMyIdentifiersAsync(); + Task AddSelfAsync(AddUserIdentifierRequest request); + Task UpdateSelfAsync(UpdateUserIdentifierRequest request); + Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); + Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request); + Task VerifySelfAsync(VerifyUserIdentifierRequest request); + Task DeleteSelfAsync(DeleteUserIdentifierRequest request); + + Task>> GetUserIdentifiersAsync(UserKey userKey); + Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); + Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); + Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); + Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request); + Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request); + Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index 021de20c..40d44f2a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -6,10 +6,16 @@ internal sealed class UAuthClient : IUAuthClient { public IFlowClient Flows { get; } public IUserClient Users { get; } + public IUserIdentifierClient Identifiers { get; } + public ICredentialClient Credentials { get; } + public IAuthorizationClient Authorization { get; } - public UAuthClient(IFlowClient flows, IUserClient users) + public UAuthClient(IFlowClient flows, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) { Flows = flows; Users = users; + Identifiers = identifiers; + Credentials = credentials; + Authorization = authorization; } } 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..148ddc34 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs @@ -0,0 +1,16 @@ +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(string? tenantId, 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..ae7defd3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Runtime, read-only user access for authentication flows. +/// Not a domain store. +/// +public interface IUserRuntimeStateProvider +{ + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index dd008ec9..238390b5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -23,6 +23,14 @@ public sealed class AccessContext public bool IsSelfAction => ActorUserKey != null && ResourceId != null && string.Equals(ActorUserKey.Value, ResourceId, StringComparison.Ordinal); public bool HasActor => ActorUserKey != null; public bool HasTarget => ResourceId != null; + + public UserKey GetTargetUserKey() + { + if (ResourceId is null) + throw new InvalidOperationException("Target user is not specified."); + + return UserKey.Parse(ResourceId, null); + } } internal sealed class EmptyAttributes : IReadOnlyDictionary 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..404b62b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class PagedResult + { + public IReadOnlyList Items { get; } + public int TotalCount { get; } + + public PagedResult(IReadOnlyList items, int totalCount) + { + Items = items; + TotalCount = totalCount; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs index e18593a3..8d077b7b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs @@ -21,7 +21,8 @@ public enum AuthFlowType PermissionQuery, UserManagement, - UserProfile, + UserProfileManagement, + UserIdentifierManagement, CredentialManagement, AuthorizationManagement, 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..6e042d46 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed record UserRuntimeRecord +{ + public UserKey UserKey { get; init; } + public bool IsActive { get; init; } + public bool IsDeleted { get; init; } + public bool Exists { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs index 3d604bcb..a8a7f035 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs @@ -86,6 +86,7 @@ private static IServiceCollection AddUltimateAuthInternal(this IServiceCollectio services.AddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs new file mode 100644 index 00000000..d0bf6adf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class SeedRunner +{ + private readonly IEnumerable _contributors; + + public SeedRunner(IEnumerable contributors) + { + _contributors = contributors; + Console.WriteLine("SeedRunner contributors:"); + foreach (var c in contributors) + { + Console.WriteLine($"- {c.GetType().FullName}"); + } + } + + public async Task RunAsync(string? tenantId, CancellationToken ct = default) + { + foreach (var c in _contributors.OrderBy(x => x.Order)) + { + await c.SeedAsync(tenantId, ct); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs index 111ab057..1d0ce66b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs @@ -5,8 +5,9 @@ public static class UAuthActions public static class Users { public const string Create = "users.create"; - public const string Delete = "users.delete"; - public const string ChangeStatus = "users.status.change"; + 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 @@ -19,29 +20,49 @@ public static class UserProfiles public static class UserIdentifiers { - public const string Get = "users.identifiers.get"; - public const string Change = "users.identifiers.change"; - public const string Verify = "users.identifiers.verify"; - public const string Delete = "users.identifiers.delete"; + 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 List = "credentials.list"; - public const string Add = "credentials.add"; - public const string Change = "credentials.change"; - public const string Revoke = "credentials.revoke"; - public const string Activate = "credentials.activate"; - public const string Delete = "credentials.delete"; + 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 ActivateAdmin = "credentials.activate.admin"; + public const string BeginResetSelf = "credentials.beginreset.self"; + public const string BeginResetAdmin = "credentials.beginreset.admin"; + public const string CompleteResetSelf = "credentials.completereset.self"; + public const string CompleteResetAdmin = "credentials.completereset.admin"; + public const string DeleteAdmin = "credentials.delete.admin"; } public static class Authorization { public static class Roles { - public const string Read = "authorization.roles.read"; - public const string Assign = "authorization.roles.assign"; - public const string Remove = "authorization.roles.remove"; + public const string ReadSelf = "authorization.roles.read.self"; + public const string ReadAdmin = "authorization.roles.read.admin"; + public const string AssignAdmin = "authorization.roles.assign.admin"; + public const string RemoveAdmin = "authorization.roles.remove.admin"; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs index 53460a6f..ff635f09 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs @@ -6,7 +6,8 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints public interface IAuthorizationEndpointHandler { Task CheckAsync(HttpContext ctx); - Task GetRolesAsync(UserKey userKey, HttpContext ctx); + Task GetMyRolesAsync(HttpContext ctx); + Task GetUserRolesAsync(UserKey userKey, HttpContext ctx); Task AssignRoleAsync(UserKey userKey, HttpContext ctx); Task RemoveRoleAsync(UserKey userKey, HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs index 352e65ec..a788a7b5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -1,14 +1,22 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ICredentialEndpointHandler { - public interface ICredentialEndpointHandler - { - Task GetAllAsync(HttpContext ctx); - Task AddAsync(HttpContext ctx); - Task ChangeAsync(string type, HttpContext ctx); - Task RevokeAsync(string type, HttpContext ctx); - Task ActivateAsync(string type, HttpContext ctx); - Task DeleteAsync(string type, HttpContext ctx); - } + Task GetAllAsync(HttpContext ctx); + Task AddAsync(HttpContext ctx); + Task ChangeAsync(string type, HttpContext ctx); + Task RevokeAsync(string type, HttpContext ctx); + Task BeginResetAsync(string type, HttpContext ctx); + Task CompleteResetAsync(string type, HttpContext ctx); + + Task GetAllAdminAsync(UserKey userKey, HttpContext ctx); + Task AddAdminAsync(UserKey userKey, HttpContext ctx); + Task RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx); } 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..e543a959 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IUserEndpointHandler +{ + Task CreateAsync(HttpContext ctx); + Task ChangeStatusSelfAsync(HttpContext ctx); + Task ChangeStatusAdminAsync(UserKey userKey, 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 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 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/IUserLifecycleEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs deleted file mode 100644 index 08cdbe69..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public interface IUserLifecycleEndpointHandler - { - Task CreateAsync(HttpContext ctx); - Task ChangeStatusAsync(HttpContext ctx); - Task DeleteAsync(HttpContext ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs deleted file mode 100644 index 7717e26d..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public interface IUserProfileAdminEndpointHandler - { - Task GetAsync(UserKey userKey, HttpContext ctx); - Task UpdateAsync(UserKey userKey, HttpContext ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs deleted file mode 100644 index afc9e364..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public interface IUserProfileEndpointHandler - { - Task GetAsync(HttpContext ctx); - Task UpdateAsync(HttpContext ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index aed3688c..2aa483c8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using static CodeBeam.UltimateAuth.Server.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Server.Endpoints { @@ -92,94 +93,164 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); } - if (options.EnableUserInfoEndpoints != false) - { - var user = group.MapGroup(""); + var user = group.MapGroup(""); + var users = group.MapGroup("/users"); + var adminUsers = group.MapGroup("/admin/users"); - user.MapPost("/userinfo", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - => await h.GetUserInfoAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); + //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", 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)); - } + // user.MapPost("/permissions/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); + //} - if (options.EnableUserLifecycleEndpoints) + if (options.EnableUserLifecycleEndpoints != false) { - var users = group.MapGroup("/users"); - - users.MapPost("/create", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) + users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - users.MapPost("/status", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) - => await h.ChangeStatusAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); // Post is intended for Auth - users.MapPost("/delete", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) - => await h.DeleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); } - if (options.EnableUserProfileEndpoints) + if (options.EnableUserProfileEndpoints != false) { - var userProfile = group.MapGroup("/users"); + users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - userProfile.MapPost("/me/get", async ([FromServices] IUserProfileEndpointHandler h, HttpContext ctx) - => await h.GetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfile)); + users.MapPost("/me/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - userProfile.MapPost("/me/update", async ([FromServices] IUserProfileEndpointHandler h, HttpContext ctx) - => await h.UpdateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); + adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + + 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.EnableAdminChangeUserProfileEndpoints) + if (options.EnableUserIdentifierEndpoints != false) { - var admin = group.MapGroup("/admin/users"); + users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + + adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - admin.MapPost("/{userKey}/profile/get", async ([FromServices] IUserProfileAdminEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - admin.MapPost("/{userKey}/profile/update", async ([FromServices] IUserProfileAdminEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UpdateAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + 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.EnableCredentialsEndpoints) + if (options.EnableCredentialsEndpoints != false) { var credentials = group.MapGroup("/credentials"); + var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); credentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.GetAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/post", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/update/{type}", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + credentials.MapPost("/{type}/change", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) => await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) => await h.RevokeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.ActivateAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.BeginResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.CompleteResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + + adminCredentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetAllAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/delete/{type}", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.DeleteAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.RevokeAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.ActivateAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.BeginResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.CompleteResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/{type}/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); } - if (options.EnableAuthorizationEndpoints) + if (options.EnableAuthorizationEndpoints != false) { var authz = group.MapGroup("/authorization"); + var adminAuthz = group.MapGroup("/admin/authorization"); authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - authz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + authz.MapPost("/users/me/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + + adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - authz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + adminAuthz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - authz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + adminAuthz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index 2dc154ba..494af80d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -255,6 +255,36 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol return services; } + //internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) + //{ + // if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) + // throw new InvalidOperationException("UltimateAuth policies already registered."); + + // var registry = new AccessPolicyRegistry(); + + // DefaultPolicySet.Register(registry); + // configure?.Invoke(registry); + // services.AddSingleton(registry); + // services.AddSingleton(sp => + // { + // var compiled = registry.Build(); + // return new DefaultAccessPolicyProvider(compiled, sp); + // }); + + // services.TryAddScoped(sp => + // { + // var invariants = sp.GetServices(); + // var globalPolicies = sp.GetServices(); + + // return new DefaultAccessAuthority(invariants, globalPolicies); + // }); + + // services.TryAddScoped(); + + + // return services; + //} + internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) { if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) @@ -264,28 +294,34 @@ internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollecti DefaultPolicySet.Register(registry); configure?.Invoke(registry); + + // 1. Registry (global, mutable until Build) services.AddSingleton(registry); - services.AddSingleton(sp => + + // 2. Compiled policy set (immutable, singleton) + services.AddSingleton(sp => { - var compiled = registry.Build(); - return new DefaultAccessPolicyProvider(compiled, sp); + var r = sp.GetRequiredService(); + return r.Build(); }); + // 3. Policy provider MUST be scoped + services.AddScoped(); + + // 4. Authority (scoped, correct) services.TryAddScoped(sp => { var invariants = sp.GetServices(); var globalPolicies = sp.GetServices(); - return new DefaultAccessAuthority(invariants, globalPolicies); }); + // 5. Orchestrator (scoped) services.TryAddScoped(); - return services; } - // ========================= // USERS (FRAMEWORK-REQUIRED) // ========================= diff --git a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs index 2df88ac8..028f6608 100644 --- a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Abstractions; namespace CodeBeam.UltimateAuth.Server.Login.Orchestrators { @@ -15,7 +16,7 @@ internal sealed class DefaultLoginOrchestrator : ILoginOrchestrator _credentialStore; // authentication private readonly ICredentialValidator _credentialValidator; - private readonly IUserStore _users; // eligible + private readonly IUserRuntimeStateProvider _users; // eligible private readonly IUserSecurityStateProvider _userSecurityStateProvider; // runtime risk private readonly ILoginAuthority _authority; private readonly ISessionOrchestrator _sessionOrchestrator; @@ -26,7 +27,7 @@ internal sealed class DefaultLoginOrchestrator : ILoginOrchestrator credentialStore, ICredentialValidator credentialValidator, - IUserStore users, + IUserRuntimeStateProvider users, IUserSecurityStateProvider userSecurityStateProvider, ILoginAuthority authority, ISessionOrchestrator sessionOrchestrator, @@ -85,7 +86,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req userKey = UserKey.FromString(converter.ToString(validatedUserId)); } - var user = await _users.FindByIdAsync(request.TenantId, validatedUserId); + var user = userKey is not null + ? await _users.GetAsync(request.TenantId, userKey.Value, ct) + : null; if (user is null || user.IsDeleted || !user.IsActive) { diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 6e3da9dc..ddf940da 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -108,11 +108,11 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public bool? EnableSessionEndpoints { get; set; } = true; public bool? EnableUserInfoEndpoints { get; set; } = true; - public bool EnableUserLifecycleEndpoints { get; set; } = true; - public bool EnableUserProfileEndpoints { get; set; } = true; - public bool EnableAdminChangeUserProfileEndpoints { get; set; } = false; - public bool EnableCredentialsEndpoints { get; set; } = true; - public bool EnableAuthorizationEndpoints { get; set; } = true; + public bool? EnableUserLifecycleEndpoints { get; set; } = true; + public bool? EnableUserProfileEndpoints { get; set; } = true; + public bool? EnableUserIdentifierEndpoints { get; set; } = true; + public bool? EnableCredentialsEndpoints { get; set; } = true; + public bool? EnableAuthorizationEndpoints { get; set; } = true; public UserIdentifierOptions UserIdentifiers { get; set; } = new(); @@ -176,7 +176,6 @@ internal UAuthServerOptions Clone() EnableUserInfoEndpoints = EnableUserInfoEndpoints, EnableUserLifecycleEndpoints = EnableUserLifecycleEndpoints, EnableUserProfileEndpoints = EnableUserProfileEndpoints, - EnableAdminChangeUserProfileEndpoints = EnableAdminChangeUserProfileEndpoints, EnableCredentialsEndpoints = EnableCredentialsEndpoints, EnableAuthorizationEndpoints = EnableAuthorizationEndpoints, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs index 3cbbf129..2e4bd92c 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace CodeBeam.UltimateAuth.Authorization.InMemory.Extensions @@ -7,8 +8,10 @@ public static class AuthorizationInMemoryExtensions { public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddSingleton(); + + // Never try add - seeding is enumerated and all contributors are added. + services.AddSingleton(); return services; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs new file mode 100644 index 00000000..cd459d7d --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryAuthorizationSeedContributor : ISeedContributor +{ + public int Order => 20; + + private readonly IUserRoleStore _roles; + private readonly IInMemoryUserIdProvider _ids; + + public InMemoryAuthorizationSeedContributor(IUserRoleStore roles, IInMemoryUserIdProvider ids) + { + _roles = roles; + _ids = ids; + } + + public async Task SeedAsync(string? tenantId, CancellationToken ct = default) + { + var adminKey = _ids.GetAdminUserId(); + + await _roles.AssignAsync(tenantId, adminKey, "Admin", ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs deleted file mode 100644 index 82af6b38..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.InMemory -{ - internal sealed class InMemoryAuthorizationSeeder : IAuthorizationSeeder - { - private readonly IUserRoleStore _roles; - private readonly IInMemoryUserIdProvider _ids; - - public InMemoryAuthorizationSeeder(IUserRoleStore roles, IInMemoryUserIdProvider ids) - { - _roles = roles; - _ids = ids; - } - - public async Task SeedAsync(CancellationToken ct = default) - { - var key = _ids.GetAdminUserId(); - await _roles.AssignAsync(null, key, "Admin", ct); - } - } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs index e10afdfc..3f54673f 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -1,50 +1,52 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; using System.Collections.Concurrent; -namespace CodeBeam.UltimateAuth.Authorization.InMemory +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryUserRoleStore : IUserRoleStore { - internal sealed class InMemoryUserRoleStore : IUserRoleStore + private readonly ConcurrentDictionary<(string? TenantId, UserKey UserKey), HashSet> _roles = new(); + + public Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - private readonly ConcurrentDictionary> _roles = new(); + ct.ThrowIfCancellationRequested(); - public InMemoryUserRoleStore(IInMemoryUserIdProvider ids) + if (_roles.TryGetValue((tenantId, userKey), out var set)) { - var key = ids.GetAdminUserId(); - _roles[key] = new HashSet + lock (set) { - "Admin" - }; + return Task.FromResult>(set.ToArray()); + } } - public Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (_roles.TryGetValue(userKey, out var set)) - return Task.FromResult>(set.ToArray()); + return Task.FromResult>(Array.Empty()); + } - return Task.FromResult>(Array.Empty()); - } + public Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - public Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + var set = _roles.GetOrAdd((tenantId, userKey), _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + lock (set) { - ct.ThrowIfCancellationRequested(); - - var set = _roles.GetOrAdd(userKey, _ => new HashSet(StringComparer.OrdinalIgnoreCase)); set.Add(role); - return Task.CompletedTask; } - public Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } - if (_roles.TryGetValue(userKey, out var set)) - set.Remove(role); + public Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - return Task.CompletedTask; + if (_roles.TryGetValue((tenantId, userKey), out var set)) + { + lock (set) + { + set.Remove(role); + } } - } + return Task.CompletedTask; + } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs index 41c69b18..99f8258b 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs @@ -48,7 +48,28 @@ public async Task CheckAsync(HttpContext ctx) : Results.Forbid(); } - public async Task GetRolesAsync(UserKey userKey, HttpContext ctx) + public async Task GetMyRolesAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.ReadSelf, + resource: "authorization.roles", + resourceId: flow.UserKey!.Value + ); + + var roles = await _roles.GetRolesAsync(accessContext, flow.UserKey!.Value, 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) @@ -56,7 +77,7 @@ public async Task GetRolesAsync(UserKey userKey, HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Authorization.Roles.Read, + action: UAuthActions.Authorization.Roles.ReadAdmin, resource: "authorization.roles", resourceId: userKey.Value ); @@ -80,7 +101,7 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Authorization.Roles.Assign, + action: UAuthActions.Authorization.Roles.AssignAdmin, resource: "authorization.roles", resourceId: userKey.Value ); @@ -99,7 +120,7 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Authorization.Roles.Remove, + action: UAuthActions.Authorization.Roles.RemoveAdmin, resource: "authorization.roles", resourceId: userKey.Value ); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index 082e8f40..5e197c4a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed class CredentialSecurityState { @@ -21,6 +19,8 @@ public CredentialSecurityState( Reason = reason; } + public static CredentialSecurityState Active { get; } = new(CredentialSecurityStatus.Active); + /// /// Determines whether the credential can be used at the given time. /// diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs index 1deeb5f8..24838b72 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs @@ -6,5 +6,7 @@ public enum CredentialSecurityStatus Revoked = 10, Locked = 20, - Expired = 30 + Expired = 30, + ResetRequested = 40, + ResetRequired = 50 } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs new file mode 100644 index 00000000..bd6cd4be --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record BeginCredentialResetRequest + { + public string? Reason { get; init; } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs new file mode 100644 index 00000000..6afdea41 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record CompleteCredentialResetRequest + { + public required string NewSecret { get; init; } + public string? Source { get; init; } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs new file mode 100644 index 00000000..468092e2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory +{ + internal sealed class InMemoryCredentialSeedContributor : ISeedContributor + { + public int Order => 10; + + private readonly ICredentialStore _credentials; + private readonly IInMemoryUserIdProvider _ids; + private readonly IUAuthPasswordHasher _hasher; + + public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) + { + _credentials = credentials; + _ids = ids; + _hasher = hasher; + } + + public async Task SeedAsync(string? tenantId, CancellationToken ct = default) + { + await SeedCredentialAsync("admin", _ids.GetAdminUserId(), tenantId, ct); + await SeedCredentialAsync("user", _ids.GetUserUserId(), tenantId, ct); + } + + private async Task SeedCredentialAsync(string login, UserKey userKey, string? tenantId, CancellationToken ct) + { + if (await _credentials.ExistsAsync(tenantId, userKey, CredentialType.Password, ct)) + return; + + await _credentials.AddAsync(tenantId, + new PasswordCredential( + userKey, + login, + _hasher.Hash(login), + CredentialSecurityState.Active, + new CredentialMetadata(DateTimeOffset.Now, null, null)), + ct); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 413a15b2..8bc957b0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -6,10 +6,12 @@ using CodeBeam.UltimateAuth.Credentials.Reference; using System.Collections.Concurrent; +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + internal sealed class InMemoryCredentialStore : ICredentialStore, ICredentialSecretStore where TUserId : notnull { - private readonly ConcurrentDictionary> _byLogin; - private readonly ConcurrentDictionary>> _byUser; + private readonly ConcurrentDictionary<(string? TenantId, string Login), InMemoryPasswordCredentialState> _byLogin; + private readonly ConcurrentDictionary<(string? TenantId, TUserId UserId), List>> _byUser; private readonly IUAuthPasswordHasher _hasher; private readonly IInMemoryUserIdProvider _userIdProvider; @@ -19,38 +21,15 @@ public InMemoryCredentialStore(IUAuthPasswordHasher hasher, IInMemoryUserIdProvi _hasher = hasher; _userIdProvider = userIdProvider; - _byLogin = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); - _byUser = new ConcurrentDictionary>>(); - - SeedDefault(); - } - - private void SeedDefault() - { - SeedUser("admin", _userIdProvider.GetAdminUserId()); - SeedUser("user", _userIdProvider.GetUserUserId()); - } - - private void SeedUser(string login, TUserId userId) - { - var state = new InMemoryPasswordCredentialState - { - UserId = userId, - Login = login, - SecretHash = _hasher.Hash(login), - Security = new CredentialSecurityState(CredentialSecurityStatus.Active), - Metadata = new CredentialMetadata(DateTimeOffset.UtcNow, null, "seed") - }; - - _byLogin[login] = state; - _byUser[userId] = new List> { state }; + _byLogin = new ConcurrentDictionary<(string?, string), InMemoryPasswordCredentialState>(); + _byUser = new ConcurrentDictionary<(string?, TUserId), List>>(); } public Task>> FindByLoginAsync(string? tenantId, string loginIdentifier, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byLogin.TryGetValue(loginIdentifier, out var state)) + if (!_byLogin.TryGetValue((tenantId, loginIdentifier), out var state)) return Task.FromResult>>(Array.Empty>()); return Task.FromResult>>(new[] { Map(state) }); @@ -60,23 +39,22 @@ public Task>> GetByUserAsync(string? te { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue(userId, out var list)) + if (!_byUser.TryGetValue((tenantId, userId), out var list)) return Task.FromResult>>(Array.Empty>()); - return Task.FromResult>>(list.Select(Map).Cast>().ToArray()); + return Task.FromResult>>(list.Select(Map).ToArray()); } public Task>> GetByUserAndTypeAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue(userId, out var list)) + if (!_byUser.TryGetValue((tenantId, userId), out var list)) return Task.FromResult>>(Array.Empty>()); return Task.FromResult>>( list.Where(c => c.Type == type) .Select(Map) - .Cast>() .ToArray()); } @@ -84,7 +62,7 @@ public Task ExistsAsync(string? tenantId, TUserId userId, CredentialType t { ct.ThrowIfCancellationRequested(); - return Task.FromResult(_byUser.TryGetValue(userId, out var list) && list.Any(c => c.Type == type)); + return Task.FromResult(_byUser.TryGetValue((tenantId, userId), out var list) && list.Any(c => c.Type == type)); } public Task AddAsync(string? tenantId, ICredential credential, CancellationToken ct = default) @@ -92,7 +70,7 @@ public Task AddAsync(string? tenantId, ICredential credential, Cancella ct.ThrowIfCancellationRequested(); if (credential is not PasswordCredential pwd) - throw new NotSupportedException("Only password credential supported in-memory."); + throw new NotSupportedException("Only password credentials are supported in-memory."); var state = new InMemoryPasswordCredentialState { @@ -103,8 +81,10 @@ public Task AddAsync(string? tenantId, ICredential credential, Cancella Metadata = pwd.Metadata }; - _byLogin[pwd.LoginIdentifier] = state; - _byUser.AddOrUpdate(pwd.UserId, + _byLogin[(tenantId, pwd.LoginIdentifier)] = state; + + _byUser.AddOrUpdate( + (tenantId, pwd.UserId), _ => new List> { state }, (_, list) => { @@ -119,7 +99,7 @@ public Task UpdateSecurityStateAsync(string? tenantId, TUserId userId, Credentia { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue(userId, out var list)) + if (_byUser.TryGetValue((tenantId, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -133,7 +113,7 @@ public Task UpdateMetadataAsync(string? tenantId, TUserId userId, CredentialType { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue(userId, out var list)) + if (_byUser.TryGetValue((tenantId, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -147,7 +127,7 @@ public Task SetAsync(string? tenantId, TUserId userId, CredentialType type, stri { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue(userId, out var list)) + if (_byUser.TryGetValue((tenantId, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -161,13 +141,13 @@ public Task DeleteAsync(string? tenantId, TUserId userId, CredentialType type, C { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue(userId, out var list)) + if (_byUser.TryGetValue((tenantId, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) { list.Remove(state); - _byLogin.TryRemove(state.Login, out _); + _byLogin.TryRemove((tenantId, state.Login), out _); } } @@ -178,10 +158,10 @@ public Task DeleteByUserAsync(string? tenantId, TUserId userId, CancellationToke { ct.ThrowIfCancellationRequested(); - if (_byUser.TryRemove(userId, out var list)) + if (_byUser.TryRemove((tenantId, userId), out var list)) { foreach (var credential in list) - _byLogin.TryRemove(credential.Login, out _); + _byLogin.TryRemove((tenantId, credential.Login), out _); } return Task.CompletedTask; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs index d8b541ff..422d72a1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace CodeBeam.UltimateAuth.Credentials.InMemory.Extensions @@ -8,8 +9,11 @@ public static class UltimateAuthCredentialsInMemoryExtensions public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) { services.TryAddScoped(typeof(InMemoryCredentialStore<>)); - services.TryAddScoped(typeof(ICredentialStore<>), typeof(InMemoryCredentialStore<>)); - services.TryAddScoped(typeof(ICredentialSecretStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddSingleton(typeof(ICredentialStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddSingleton(typeof(ICredentialSecretStore<>), typeof(InMemoryCredentialStore<>)); + + // Never try add seed + services.AddSingleton(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs index 5d3acf6d..78526c99 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs @@ -7,16 +7,12 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class ActivateCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public ActivateCredentialCommand(IEnumerable policies, Func> execute) + public ActivateCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs index 46c5d2d3..60fa352d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs @@ -7,17 +7,13 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference { internal sealed class AddCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public AddCredentialCommand(IEnumerable policies, Func> execute) + public AddCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs new file mode 100644 index 00000000..e3e6fd7a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class BeginCredentialResetCommand : IAccessCommand +{ + private readonly Func> _execute; + + public BeginCredentialResetCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs index 12a3463d..14b476de 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; + using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -7,16 +6,12 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class ChangeCredentialCommand: IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public ChangeCredentialCommand(IEnumerable policies, Func> execute) + public ChangeCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs new file mode 100644 index 00000000..7bf3a0f6 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class CompleteCredentialResetCommand : IAccessCommand +{ + private readonly Func> _execute; + + public CompleteCredentialResetCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs index 625b6ff1..ae290b3d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs @@ -1,22 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class DeleteCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public DeleteCredentialCommand(IEnumerable policies, Func> execute) + public DeleteCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs index 8083e12c..7c3461ce 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs @@ -1,23 +1,17 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference { internal sealed class GetAllCredentialsCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public GetAllCredentialsCommand(IEnumerable policies, Func> execute) + public GetAllCredentialsCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context)=> _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs index c99980e0..70bf7851 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs @@ -1,22 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class RevokeCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public RevokeCredentialCommand( IEnumerable policies, Func> execute) + public RevokeCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs index f0976bcc..113e914d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs @@ -1,22 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference { internal sealed class SetInitialCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func _execute; - public SetInitialCredentialCommand(IEnumerable policies, Func execute) + public SetInitialCredentialCommand(Func execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs index b41074a7..25ea1b96 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs @@ -1,161 +1,302 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Credentials.Reference +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class DefaultCredentialEndpointHandler : ICredentialEndpointHandler { - public sealed class DefaultCredentialEndpointHandler : ICredentialEndpointHandler + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUserCredentialsService _credentials; + + public DefaultCredentialEndpointHandler( + IAuthFlowContextAccessor authFlow, + IAccessContextFactory accessContextFactory, + IUserCredentialsService credentials) { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserCredentialsService _credentials; + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _credentials = credentials; + } - public DefaultCredentialEndpointHandler( - IAuthFlowContextAccessor authFlow, - IAccessContextFactory accessContextFactory, - IUserCredentialsService credentials) - { - _authFlow = authFlow; - _accessContextFactory = accessContextFactory; - _credentials = credentials; - } + public async Task GetAllAsync(HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - private bool TryGetAuthenticatedUser(out AuthFlowContext flow, out IResult? error) - { - flow = _authFlow.Current; + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ListSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - if (!flow.IsAuthenticated || flow.UserKey is null) - { - error = Results.Unauthorized(); - return false; - } + var result = await _credentials.GetAllAsync(accessContext, ctx.RequestAborted); + return Results.Ok(result); + } - error = null; - return true; - } + public async Task AddAsync(HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - public async Task GetAllAsync(HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.List, - resource: "credentials", - resourceId: flow.UserKey.Value); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.AddSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - var result = await _credentials.GetAllAsync( - accessContext, - ctx.RequestAborted); + var result = await _credentials.AddAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); + } - return Results.Ok(result); - } + public async Task ChangeAsync(string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - public async Task AddAsync(HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + if (!TryParseType(type, out var credentialType, out error)) + return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Add, - resource: "credentials", - resourceId: flow.UserKey.Value); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ChangeSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - var result = await _credentials.AddAsync( - accessContext, - request, - ctx.RequestAborted); + var result = await _credentials.ChangeAsync( + accessContext, credentialType, request, ctx.RequestAborted); - return Results.Ok(result); - } + return Results.Ok(result); + } - public async Task ChangeAsync(string type, HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + public async Task RevokeAsync(string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - if (!CredentialTypeParser.TryParse(type, out var credentialType)) - return Results.BadRequest($"Unsupported credential type: {type}"); + if (!TryParseType(type, out var credentialType, out error)) + return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Change, - resource: "credentials", - resourceId: flow.UserKey.Value); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.RevokeSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - var result = await _credentials.ChangeAsync( - accessContext, - credentialType, - request, - ctx.RequestAborted); + await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } - return Results.Ok(result); - } + public async Task BeginResetAsync(string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - public async Task RevokeAsync(string type, HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + if (!TryParseType(type, out var credentialType, out error)) + return error!; - if (!CredentialTypeParser.TryParse(type, out var credentialType)) - return Results.BadRequest($"Unsupported credential type: {type}"); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.BeginResetSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Revoke, - resource: "credentials", - resourceId: flow.UserKey.Value); + await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } - await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); - return Results.NoContent(); - } + public async Task CompleteResetAsync(string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - public async Task ActivateAsync(string type, HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + if (!TryParseType(type, out var credentialType, out error)) + return error!; - if (!CredentialTypeParser.TryParse(type, out var credentialType)) - return Results.BadRequest($"Unsupported credential type: {type}"); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Activate, - resource: "credentials", - resourceId: flow.UserKey.Value); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.CompleteResetSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - await _credentials.ActivateAsync(accessContext, credentialType, ctx.RequestAborted); - return Results.NoContent(); - } + await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } - public async Task DeleteAsync(string type, HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + 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 RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); - if (!CredentialTypeParser.TryParse(type, out var credentialType)) - return Results.BadRequest($"Unsupported credential type: {type}"); + if (!TryParseType(type, out var credentialType, out var error)) + return error!; - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Delete, - resource: "credentials", - resourceId: flow.UserKey.Value); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - await _credentials.DeleteAsync(accessContext, credentialType, ctx.RequestAborted); - return Results.NoContent(); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.RevokeAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + + return Results.NoContent(); + } + + public async Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (!TryParseType(type, out var credentialType, out var error)) + return error!; + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ActivateAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.ActivateAsync(accessContext, credentialType, ctx.RequestAborted); + + return Results.NoContent(); + } + + public async Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (!TryParseType(type, out var credentialType, out var error)) + return error!; + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.DeleteAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.DeleteAsync(accessContext, credentialType, ctx.RequestAborted); + + return Results.NoContent(); + } + + public async Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + if (!TryParseType(type, out var credentialType, out 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, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } + + public async Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + if (!TryParseType(type, out var credentialType, out 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, credentialType, 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; } + private static bool TryParseType(string type, out CredentialType credentialType, out IResult? error) + { + if (!CredentialTypeParser.TryParse(type, out credentialType)) + { + error = Results.BadRequest($"Unsupported credential type: {type}"); + 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 index 083a5f7a..0f191c3f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Users.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -12,6 +13,7 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); return services; } 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..29084e7b --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Users.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegration +{ + private readonly ICredentialStore _credentialStore; + private readonly IUAuthPasswordHasher _passwordHasher; + private readonly IClock _clock; + + public PasswordUserLifecycleIntegration(ICredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) + { + _credentialStore = credentialStore; + _passwordHasher = passwordHasher; + _clock = clock; + } + + public async Task OnUserCreatedAsync(string? tenantId, 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 = new PasswordCredential( + userId: userKey, + loginIdentifier: r.PrimaryIdentifierValue!, + secretHash: hash, + security: new CredentialSecurityState(CredentialSecurityStatus.Active, null, null, null), + metadata: new CredentialMetadata(_clock.UtcNow, _clock.UtcNow, null)); + + await _credentialStore.AddAsync(tenantId, credential, ct); + } + + public async Task OnUserDeletedAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct) + { + await _credentialStore.DeleteByUserAsync(tenantId, userKey, ct); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs index 27eb76c3..99f0b219 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs @@ -33,7 +33,7 @@ public async Task GetAllAsync(AccessContext context, Cance { ct.ThrowIfCancellationRequested(); - var cmd = new GetAllCredentialsCommand(Array.Empty(), + var cmd = new GetAllCredentialsCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -65,7 +65,7 @@ public async Task AddAsync(AccessContext context, AddCreden { ct.ThrowIfCancellationRequested(); - var cmd = new AddCredentialCommand(Array.Empty(), + var cmd = new AddCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -102,7 +102,7 @@ public async Task ChangeAsync(AccessContext context, Cre { ct.ThrowIfCancellationRequested(); - var cmd = new ChangeCredentialCommand(Array.Empty(), + var cmd = new ChangeCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -123,7 +123,7 @@ public async Task RevokeAsync(AccessContext context, Cre { ct.ThrowIfCancellationRequested(); - var cmd = new RevokeCredentialCommand(Array.Empty(), + var cmd = new RevokeCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -146,7 +146,7 @@ public async Task ActivateAsync(AccessContext context, C { ct.ThrowIfCancellationRequested(); - var cmd = new ActivateCredentialCommand(Array.Empty(), + var cmd = new ActivateCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -161,11 +161,46 @@ public async Task ActivateAsync(AccessContext context, C return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } + public async Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct) + { + var cmd = new BeginCredentialResetCommand(async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var security = new CredentialSecurityState(CredentialSecurityStatus.ResetRequested, reason: request.Reason); + + await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + return CredentialActionResult.Success(); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct) + { + var cmd = new CompleteCredentialResetCommand(async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var hash = _hasher.Hash(request.NewSecret); + + await _secrets.SetAsync(context.ResourceTenantId, userKey, type, hash, innerCt); + + var security = new CredentialSecurityState(CredentialSecurityStatus.Active); + await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + return CredentialActionResult.Success(); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + public async Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new DeleteCredentialCommand(Array.Empty(), + var cmd = new DeleteCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs index b943d0a5..eaddd57a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials.Reference; @@ -16,5 +15,9 @@ public interface IUserCredentialsService Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default); + Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct = default); + + Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct = default); + Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs b/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs index 3110e446..1c13458c 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs @@ -1,3 +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/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs index 940e1011..f63214b0 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Policies.Registry; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Policies.Registry; +using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Policies.Defaults; @@ -6,20 +8,15 @@ internal static class DefaultPolicySet { public static void Register(AccessPolicyRegistry registry) { - // Everyone must be authenticated + // Invariant registry.Add("", _ => new RequireAuthenticatedPolicy()); - - // Self operations - registry.Add("users.profile.", _ => new RequireSelfPolicy()); - registry.Add("credentials.self.", _ => new RequireSelfPolicy()); - - // Admin-only - registry.Add("admin.", _ => new RequireAdminPolicy()); - - // Self OR admin - registry.Add("users.", _ => new RequireSelfOrAdminPolicy()); - - // Global safety registry.Add("", _ => new DenyCrossTenantPolicy()); + registry.Add("", sp => new RequireActiveUserPolicy(sp.GetRequiredService())); + + // Intent-based + registry.Add("", _ => new RequireSelfPolicy()); + registry.Add("", _ => new RequireAdminPolicy()); + registry.Add("", _ => new RequireSelfOrAdminPolicy()); + registry.Add("", _ => new RequireSystemPolicy()); } } 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..1fc8f4be --- /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; + +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.ActorTenantId, 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; + + return !AllowedForInactive.Any(prefix => context.Action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + private static readonly string[] AllowedForInactive = + { + "users.status.change.", + "credentials.password.reset.", + "login.", + "reauth." + }; + } +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs index d926a28c..97702678 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs @@ -21,5 +21,5 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("admin_required"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".admin"); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs index 3da53e95..52e75a09 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs @@ -23,5 +23,5 @@ public AccessDecision Decide(AccessContext context) return AccessDecision.Deny("self_or_admin_required"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) => !context.Action.EndsWith(".self") && !context.Action.EndsWith(".admin"); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs index a92ab2bf..ae6a4dd4 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs @@ -15,5 +15,5 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("not_self"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) => 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..0524b71f --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs @@ -0,0 +1,15 @@ +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/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs index 77f1c19a..f02ebfcb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs @@ -2,28 +2,20 @@ { public enum UserStatus { - // Normal state + Active = 0, - // User initiated SelfSuspended = 10, - // Administrative actions Disabled = 20, Suspended = 30, - // Security / risk based Locked = 40, RiskHold = 50, - // Lifecycle PendingActivation = 60, PendingVerification = 70, - // Terminal (soft-delete) Deactivated = 80, - - // Soft // TODO: User domain already have IsDeleted, this may remove - Deleted = 90 } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs similarity index 62% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs index 45bf9951..5ebd9a46 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs @@ -1,24 +1,25 @@ namespace CodeBeam.UltimateAuth.Users.Contracts { - public sealed record UserProfileDto + public sealed record UserViewDto { public string UserKey { get; init; } = default!; public string? UserName { get; init; } - public string? Email { 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? Phone { get; init; } + public string? Bio { get; init; } + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } public bool EmailVerified { get; init; } public bool PhoneVerified { get; init; } - public UserStatus Status { get; init; } public DateTimeOffset? CreatedAt { get; init; } - public DateTimeOffset? LastLoginAt { get; init; } + //public DateTimeOffset? LastLoginAt { get; init; } public IReadOnlyDictionary? Metadata { get; init; } } 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..39de26bd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record AddUserIdentifierRequest + { + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; + public bool IsPrimary { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs similarity index 80% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs index 842a482b..d88b519d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts { - public sealed class ChangeUserStatusRequest + public sealed class ChangeUserStatusAdminRequest { public required UserKey UserKey { get; init; } public required UserStatus 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..aaa7ad0c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public class ChangeUserStatusSelfRequest + { + public required UserStatus 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 index dc5fe86d..ff720ce8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs @@ -2,35 +2,21 @@ public sealed record CreateUserRequest { - /// - /// Primary identifier (username, email, external id). - /// Interpretation is application-specific. - /// - public required string Identifier { get; init; } + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } - /// - /// Optional password. - /// If null, user may be invited or use external login. - /// public string? Password { get; init; } - public string? DisplayName { get; set; } + 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; } - public string? TenantId { get; init; } - - /// - /// Initial user status. - /// Defaults to Active. - /// - public UserStatus InitialStatus { get; init; } = UserStatus.Active; - - /// - /// Optional initial profile data. - /// - public UserProfileInput? Profile { get; init; } - - /// - /// Optional custom metadata. - /// - public IReadOnlyDictionary? Metadata { get; init; } + public UserIdentifierType? PrimaryIdentifierType { get; init; } + public string? PrimaryIdentifierValue { get; init; } + public bool PrimaryIdentifierVerified { get; init; } = false; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs index 0ed5ec45..f24058c1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs @@ -5,7 +5,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts { public sealed class DeleteUserRequest { - public required UserKey UserKey { get; init; } public DeleteMode Mode { get; init; } = DeleteMode.Soft; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs new file mode 100644 index 00000000..66802a50 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record SetPrimaryUserIdentifierRequest + { + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; + } +} 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..f17e69b8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UnsetPrimaryUserIdentifierRequest + { + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs index 3503699b..018aac82 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs @@ -5,7 +5,13 @@ public sealed record UpdateProfileRequest public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } - public string? PhoneNumber { 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..880d5916 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UpdateUserIdentifierRequest + { + public UserIdentifierType Type { get; init; } + public string OldValue { get; init; } = default!; + public string NewValue { get; init; } = default!; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs index 5b642d14..30049f2f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -3,6 +3,6 @@ public sealed record VerifyUserIdentifierRequest { public required UserIdentifierType Type { get; init; } - public required string Code { get; init; } + public required string Value { get; init; } } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs index a30ecc5c..641d5c47 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Extensions.DependencyInjection; @@ -10,14 +11,15 @@ public static class UltimateAuthUsersInMemoryExtensions { public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) { - services.TryAddScoped, InMemoryUserStore>(); services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton, InMemoryUserIdProvider>(); + // Seed never try add + services.AddSingleton(); + return services; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs new file mode 100644 index 00000000..4a04f938 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -0,0 +1,73 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.InMemory +{ + internal sealed class InMemoryUserSeedContributor : ISeedContributor + { + public int Order => 0; + + private readonly IUserLifecycleStore _lifecycle; + private readonly IUserProfileStore _profiles; + private readonly IUserIdentifierStore _identifiers; + private readonly IInMemoryUserIdProvider _ids; + private readonly IClock _clock; + + public InMemoryUserSeedContributor( + IUserLifecycleStore lifecycle, + IUserProfileStore profiles, + IUserIdentifierStore identifiers, + IInMemoryUserIdProvider ids, + IClock clock) + { + _lifecycle = lifecycle; + _profiles = profiles; + _ids = ids; + _identifiers = identifiers; + _clock = clock; + } + + public async Task SeedAsync(string? tenantId, CancellationToken ct = default) + { + await SeedUserAsync(tenantId, _ids.GetAdminUserId(), "Administrator", "admin", ct); + await SeedUserAsync(tenantId, _ids.GetUserUserId(), "User", "user", ct); + } + + private async Task SeedUserAsync(string? tenantId, UserKey userKey, string displayName, string username, CancellationToken ct) + { + if (await _lifecycle.ExistsAsync(tenantId, userKey, ct)) + return; + + await _lifecycle.CreateAsync(tenantId, + new UserLifecycle + { + UserKey = userKey, + Status = UserStatus.Active, + CreatedAt = _clock.UtcNow + }, ct); + + await _profiles.CreateAsync(tenantId, + new UserProfile + { + UserKey = userKey, + DisplayName = displayName, + CreatedAt = _clock.UtcNow + }, ct); + + await _identifiers.CreateAsync(tenantId, + new UserIdentifier + { + UserKey = userKey, + Type = UserIdentifierType.Username, + Value = username, + IsPrimary = true, + IsVerified = true, + CreatedAt = _clock.UtcNow + }, ct); + } + } + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index f0ba8dde..ec9f36bf 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -2,132 +2,180 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Users.InMemory { - internal sealed class InMemoryUserIdentifierStore : IUserIdentifierStore + public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore { - private readonly ConcurrentDictionary<(string? TenantId, UserKey UserKey), List> _byUser = new(); - private readonly ConcurrentDictionary<(string? TenantId, UserIdentifierType Type, string Value), UserKey> _lookup = new(); + private readonly Dictionary<(string? TenantId, UserIdentifierType Type, string Value), UserIdentifier> _store = new(); - public Task> GetAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + return Task.FromResult(_store.TryGetValue((tenantId, type, value), out var id) && !id.IsDeleted); + } - var key = (tenantId, userKey); + public Task GetAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) + { + if (!_store.TryGetValue((tenantId, type, value), out var id)) + return Task.FromResult(null); - if (_byUser.TryGetValue(key, out var list)) - return Task.FromResult>(list.ToArray()); + if (id.IsDeleted) + return Task.FromResult(null); - return Task.FromResult>(Array.Empty()); + return Task.FromResult(id); } - public Task> GetByTypeAsync(string? tenantId, UserKey userKey, UserIdentifierType type, CancellationToken ct = default) + public Task> GetByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var result = _store.Values + .Where(x => x.TenantId == tenantId) + .Where(x => x.UserKey == userKey) + .Where(x => !x.IsDeleted) + .OrderByDescending(x => x.IsPrimary) + .ThenBy(x => x.CreatedAt) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } - var key = (tenantId, userKey); + public Task CreateAsync(string? tenantId, UserIdentifier identifier, CancellationToken ct = default) + { + var key = (tenantId, identifier.Type, identifier.Value); - if (_byUser.TryGetValue(key, out var list)) - { - var result = list.Where(x => x.Type == type).ToArray(); - return Task.FromResult>(result); - } + if (_store.TryGetValue(key, out var existing) && !existing.IsDeleted) + throw new InvalidOperationException("Identifier already exists."); - return Task.FromResult>(Array.Empty()); + _store[key] = identifier; + return Task.CompletedTask; } - public Task SetAsync(string? tenantId, UserKey userKey, UserIdentifierRecord record, CancellationToken ct = default) + public Task UpdateValueAsync(string? tenantId, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var userKeyTuple = (tenantId, userKey); - var lookupKey = (tenantId, record.Type, record.Value); + if (string.Equals(oldValue, newValue, StringComparison.Ordinal)) + throw new InvalidOperationException("identifier_value_unchanged"); - var list = _byUser.GetOrAdd(userKeyTuple, _ => new List()); + var oldKey = (tenantId, type, oldValue); - // replace if same type+value exists - var existingIndex = list.FindIndex(x => x.Type == record.Type && StringComparer.OrdinalIgnoreCase.Equals(x.Value, record.Value)); + if (!_store.TryGetValue(oldKey, out var identifier) || identifier.IsDeleted) + throw new InvalidOperationException("identifier_not_found"); - if (existingIndex >= 0) - { - list[existingIndex] = record; - } - else - { - list.Add(record); - _lookup[lookupKey] = userKey; - } + var newKey = (tenantId, type, newValue); + + if (_store.ContainsKey(newKey)) + throw new InvalidOperationException("identifier_value_already_exists"); + + _store.Remove(oldKey); + + identifier.Value = newValue; + identifier.IsVerified = false; + identifier.VerifiedAt = null; + identifier.UpdatedAt = updatedAt; + + _store[newKey] = identifier; return Task.CompletedTask; } - public Task MarkVerifiedAsync(string? tenantId, UserKey userKey, UserIdentifierType type, DateTimeOffset verifiedAt, CancellationToken ct = default) + public Task MarkVerifiedAsync(string? tenantId, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, type, value); - var key = (tenantId, userKey); + if (!_store.TryGetValue(key, out var id) || id.IsDeleted) + throw new InvalidOperationException("Identifier not found."); - if (!_byUser.TryGetValue(key, out var list)) + if (id.IsVerified) return Task.CompletedTask; - for (int i = 0; i < list.Count; i++) - { - if (list[i].Type == type && !list[i].VerifiedAt.HasValue) - { - list[i] = list[i] with - { - VerifiedAt = verifiedAt - }; - } - } + id.IsVerified = true; + id.VerifiedAt = verifiedAt; return Task.CompletedTask; } - public Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) + public Task SetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + foreach (var id in _store.Values.Where(x => + x.TenantId == tenantId && + x.UserKey == userKey && + x.Type == type && + !x.IsDeleted && + x.IsPrimary)) + { + id.IsPrimary = false; + } var key = (tenantId, type, value); - return Task.FromResult(_lookup.ContainsKey(key)); + + if (!_store.TryGetValue(key, out var target) || target.IsDeleted) + throw new InvalidOperationException("Identifier not found."); + + target.IsPrimary = true; + return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, DeleteMode mode, CancellationToken ct = default) + public Task UnsetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, type, value); + + if (!_store.TryGetValue(key, out var target) || target.IsDeleted) + throw new InvalidOperationException("Identifier not found."); + + target.IsPrimary = false; + return Task.CompletedTask; + } - var userKeyTuple = (tenantId, userKey); - var lookupKey = (tenantId, type, value); + public Task DeleteAsync(string? tenantId, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + var key = (tenantId, type, value); - if (!_byUser.TryGetValue(userKeyTuple, out var list)) + if (!_store.TryGetValue(key, out var id)) return Task.CompletedTask; - var index = list.FindIndex(x => x.Type == type && StringComparer.OrdinalIgnoreCase.Equals(x.Value, value)); + if (mode == DeleteMode.Hard) + { + _store.Remove(key); + return Task.CompletedTask; + } - if (index < 0) + if (id.IsDeleted) return Task.CompletedTask; - var record = list[index]; + id.IsDeleted = true; + id.DeletedAt = deletedAt; + id.IsPrimary = false; - if (mode == DeleteMode.Soft) - { - if (record.DeletedAt.HasValue) - return Task.CompletedTask; + return Task.CompletedTask; + } - list[index] = record with - { - DeletedAt = DateTimeOffset.UtcNow - }; - } - else + public Task DeleteByUserAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + var identifiers = _store.Values + .Where(x => x.TenantId == tenantId) + .Where(x => x.UserKey == userKey) + .ToList(); + + foreach (var id in identifiers) { - list.RemoveAt(index); - _lookup.TryRemove(lookupKey, out _); + if (mode == DeleteMode.Hard) + { + _store.Remove((tenantId, id.Type, id.Value)); + } + else + { + if (id.IsDeleted) + continue; + + id.IsDeleted = true; + id.DeletedAt = deletedAt; + id.IsPrimary = false; + } } return Task.CompletedTask; } + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index 6038c12c..0302cc22 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -1,150 +1,110 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using CodeBeam.UltimateAuth.Users.Reference.Domain; -using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserLifecycleStore : IUserLifecycleStore { - private readonly ConcurrentDictionary _users = new(); - private readonly IInMemoryUserIdProvider _idProvider; - private readonly IClock _clock; + private readonly Dictionary<(string?, UserKey), UserLifecycle> _store = new(); - public InMemoryUserLifecycleStore(IInMemoryUserIdProvider idProvider, IClock clock) + public Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - _idProvider = idProvider; - _clock = clock; - SeedDefault(); + return Task.FromResult(_store.TryGetValue((tenantId, userKey), out var entity) && !entity.IsDeleted); } - private void SeedDefault() + public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - CreateSeedUser(_idProvider.GetAdminUserId(), "admin"); - CreateSeedUser(_idProvider.GetUserUserId(), "user"); - } + if (!_store.TryGetValue((tenantId, userKey), out var entity)) + return Task.FromResult(null); - private void CreateSeedUser(UserKey userKey, string identifier) - { - var now = _clock.UtcNow; + if (entity.IsDeleted) + return Task.FromResult(null); - var profile = new ReferenceUserProfile - { - UserKey = userKey, - Email = identifier, - DisplayName = identifier == "admin" - ? "Administrator" - : "Standard User", - Status = UserStatus.Active, - IsDeleted = false, - CreatedAt = now, - UpdatedAt = now, - DeletedAt = null - }; - - _users.TryAdd( - new UserIdentity(null, userKey), - profile); + return Task.FromResult(entity); } - public Task CreateAsync(string? tenantId, ReferenceUserProfile user, CancellationToken ct = default) + public Task> QueryAsync(string? tenantId, UserLifecycleQuery query, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var baseQuery = _store.Values + .Where(x => x?.UserKey != null) + .Where(x => x.TenantId == tenantId); - ArgumentNullException.ThrowIfNull(user); + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); - var identity = new UserIdentity(tenantId, user.UserKey); + if (query.Status != null) + baseQuery = baseQuery.Where(x => x.Status == query.Status); - if (!_users.TryAdd(identity, InitializeUser(user))) - { - throw new InvalidOperationException($"User '{user.UserKey}' already exists in tenant '{tenantId ?? ""}'."); - } + var totalCount = baseQuery.Count(); - return Task.CompletedTask; + var items = baseQuery + .OrderBy(x => x.CreatedAt) + .Skip(query.Skip) + .Take(query.Take) + .ToList() + .AsReadOnly(); + + return Task.FromResult(new PagedResult(items, totalCount)); } - public Task UpdateStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default) + public Task CreateAsync(string? tenantId, UserLifecycle lifecycle, CancellationToken ct = default) { - var identity = new UserIdentity(tenantId, userKey); - - if (!_users.TryGetValue(identity, out var user)) - throw new InvalidOperationException($"User '{userKey}' does not exist."); + var key = (tenantId, lifecycle.UserKey); - if (user.IsDeleted) - throw new InvalidOperationException($"User '{userKey}' is deleted."); + if (_store.ContainsKey(key)) + throw new InvalidOperationException("UserLifecycle already exists."); - if (user.Status == status) - return Task.CompletedTask; + _store[key] = lifecycle; + return Task.CompletedTask; + } - if (!IsValidStatusTransition(user.Status, status)) - throw new InvalidOperationException($"Invalid status transition from '{user.Status}' to '{status}'."); + public Task ChangeStatusAsync(string? tenantId, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default) + { + if (!_store.TryGetValue((tenantId, userKey), out var entity) || entity.IsDeleted) + throw new InvalidOperationException("UserLifecycle not found."); - user.Status = status; - user.UpdatedAt = DateTimeOffset.UtcNow; + entity.Status = newStatus; + entity.UpdatedAt = updatedAt; return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset at, CancellationToken ct = default) + public Task ChangeSecurityStampAsync(string? tenantId, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + if (!_store.TryGetValue((tenantId, userKey), out var entity) || entity.IsDeleted) + throw new InvalidOperationException("UserLifecycle not found."); - var identity = new UserIdentity(tenantId, userKey); - - if (!_users.TryGetValue(identity, out var user)) - { + if (entity.SecurityStamp == newSecurityStamp) return Task.CompletedTask; - } - switch (mode) - { - case DeleteMode.Soft: - if (user.IsDeleted) - return Task.CompletedTask; - - user.Status = UserStatus.Deleted; - user.IsDeleted = true; - user.DeletedAt = at; - user.UpdatedAt = at; - break; - - case DeleteMode.Hard: - _users.TryRemove(identity, out _); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown delete mode."); - } + entity.SecurityStamp = newSecurityStamp; + entity.UpdatedAt = updatedAt; return Task.CompletedTask; } - private static ReferenceUserProfile InitializeUser(ReferenceUserProfile user) + public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { - return user with - { - Status = user.Status == default ? UserStatus.Active : user.Status, - CreatedAt = user.CreatedAt == default ? DateTimeOffset.UtcNow : user.CreatedAt, - UpdatedAt = DateTimeOffset.UtcNow, - IsDeleted = false, - DeletedAt = null - }; - } + var key = (tenantId, userKey); - private static bool IsValidStatusTransition(UserStatus from, UserStatus to) - { - return from switch + if (!_store.TryGetValue(key, out var entity)) + return Task.CompletedTask; + + if (mode == DeleteMode.Hard) { - UserStatus.Active => to is UserStatus.Suspended or UserStatus.Disabled, - UserStatus.Suspended => to is UserStatus.Active or UserStatus.Disabled, - UserStatus.Disabled => to is UserStatus.Active, - _ => false - }; - } + _store.Remove(key); + return Task.CompletedTask; + } + + // Soft delete (idempotent) + if (entity.IsDeleted) + return Task.CompletedTask; - private readonly record struct UserIdentity(string? TenantId, UserKey UserKey); + entity.IsDeleted = true; + entity.DeletedAt = deletedAt; + + return Task.CompletedTask; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 61008cfc..162f1100 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,131 +1,102 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using CodeBeam.UltimateAuth.Users.Reference.Domain; -using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Users.InMemory; -internal sealed class InMemoryUserProfileStore : IUserProfileStore +public sealed class InMemoryUserProfileStore : IUserProfileStore { - private readonly ConcurrentDictionary _profiles = new(); - private readonly IInMemoryUserIdProvider _idProvider; - private readonly IClock _clock; + private readonly Dictionary<(string? TenantId, UserKey UserKey), UserProfile> _store = new(); - internal IEnumerable AllProfiles => _profiles.Values; - - public InMemoryUserProfileStore(IInMemoryUserIdProvider idProvider, IClock clock) + public Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - _idProvider = idProvider; - _clock = clock; - SeedDefault(); + return Task.FromResult(_store.TryGetValue((tenantId, userKey), out var profile) && profile.DeletedAt == null); } - private void SeedDefault() + public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - SeedProfile(_idProvider.GetAdminUserId(), "Administrator"); - SeedProfile(_idProvider.GetUserUserId(), "Standard User"); - } + if (!_store.TryGetValue((tenantId, userKey), out var profile)) + return Task.FromResult(null); - private void SeedProfile(UserKey userKey, string displayName) - { - var now = _clock.UtcNow; + if (profile.DeletedAt != null) + return Task.FromResult(null); - _profiles[userKey] = new ReferenceUserProfile - { - UserKey = userKey, - DisplayName = displayName, - Status = UserStatus.Active, - CreatedAt = now, - UpdatedAt = now - }; + return Task.FromResult(profile); } - public Task CreateAsync(string? tenantId, ReferenceUserProfile profile, CancellationToken ct = default) + public Task> QueryAsync(string? tenantId, UserProfileQuery query, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var baseQuery = _store.Values + .Where(x => x.TenantId == tenantId); - var mem = new ReferenceUserProfile - { - UserKey = profile.UserKey, - FirstName = profile.FirstName, - LastName = profile.LastName, - DisplayName = profile.DisplayName, - Email = profile.Email, - Status = profile.Status, - IsDeleted = profile.IsDeleted, - CreatedAt = profile.CreatedAt, - UpdatedAt = profile.UpdatedAt, - DeletedAt = profile.DeletedAt - }; - - if (!_profiles.TryAdd(profile.UserKey, mem)) - throw new InvalidOperationException($"User profile '{profile.UserKey}' already exists."); + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => x.DeletedAt == null); - return Task.CompletedTask; + var totalCount = baseQuery.Count(); + + var items = baseQuery + .OrderBy(x => x.CreatedAt) + .Skip(query.Skip) + .Take(query.Take) + .ToList() + .AsReadOnly(); + + return Task.FromResult(new PagedResult(items, totalCount)); } - public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task CreateAsync(string? tenantId, UserProfile profile, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, profile.UserKey); - if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) - return Task.FromResult(null); + if (_store.ContainsKey(key)) + throw new InvalidOperationException("UserProfile already exists."); - return Task.FromResult(Map(profile)); + _store[key] = profile; + return Task.CompletedTask; } - public Task UpdateAsync(string? tenantId, UserKey userKey, UpdateProfileRequest request, CancellationToken ct = default) + public Task UpdateAsync(string? tenantId, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, userKey); + + if (!_store.TryGetValue(key, out var existing) || existing.DeletedAt != null) + throw new InvalidOperationException("UserProfile not found."); - if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) - throw new InvalidOperationException("User profile does not exist."); + existing.FirstName = update.FirstName; + existing.LastName = update.LastName; + existing.DisplayName = update.DisplayName; + existing.BirthDate = update.BirthDate; + existing.Gender = update.Gender; + existing.Bio = update.Bio; + existing.Language = update.Language; + existing.TimeZone = update.TimeZone; + existing.Culture = update.Culture; + existing.Metadata = update.Metadata; - profile.FirstName = request.FirstName; - profile.LastName = request.LastName; - profile.DisplayName = request.DisplayName; - profile.UpdatedAt = DateTimeOffset.UtcNow; + existing.UpdatedAt = updatedAt; return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default) + public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, userKey); + + if (!_store.TryGetValue(key, out var profile)) + return Task.CompletedTask; if (mode == DeleteMode.Hard) { - _profiles.TryRemove(userKey, out _); + _store.Remove(key); return Task.CompletedTask; } - if (!_profiles.TryGetValue(userKey, out var profile)) - throw new InvalidOperationException("User profile does not exist."); + if (profile.IsDeleted) + return Task.CompletedTask; profile.IsDeleted = true; - profile.Status = UserStatus.Deleted; - profile.DeletedAt = DateTimeOffset.UtcNow; - profile.UpdatedAt = profile.DeletedAt; + profile.DeletedAt = deletedAt; return Task.CompletedTask; } - - private static ReferenceUserProfile Map(ReferenceUserProfile profile) - => new() - { - UserKey = profile.UserKey, - FirstName = profile.FirstName, - LastName = profile.LastName, - DisplayName = profile.DisplayName, - Email = profile.Email, - Status = profile.Status, - CreatedAt = profile.CreatedAt, - UpdatedAt = profile.UpdatedAt, - IsDeleted = profile.IsDeleted, - DeletedAt = profile.DeletedAt - }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs deleted file mode 100644 index 32a46ae0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs +++ /dev/null @@ -1,86 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference; -using CodeBeam.UltimateAuth.Users.Reference.Domain; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserStore : IUserStore -{ - private readonly InMemoryUserLifecycleStore _lifecycle; - private readonly IUserProfileStore _profiles; - - public InMemoryUserStore( - InMemoryUserLifecycleStore lifecycle, - IUserProfileStore profiles) - { - _lifecycle = lifecycle; - _profiles = profiles; - } - - public async Task?> FindByIdAsync( - string? tenantId, - UserKey userId, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - throw new NotImplementedException(); - //var lifecycle = await _lifecycle.GetAsync(tenantId, userId, ct); - //if (lifecycle is null || lifecycle.IsDeleted) - // return null; - - //var profile = await _profiles.GetAsync(tenantId, userId, ct); - - //return new AuthUserRecord - //{ - // Id = userId, - // Identifier = - // profile?.Email ?? - // profile?.DisplayName ?? - // userId.ToString(), - - // IsActive = lifecycle.Status == UserStatus.Active, - // IsDeleted = lifecycle.IsDeleted, - // CreatedAt = lifecycle.CreatedAt, - // DeletedAt = lifecycle.DeletedAt - //}; - } - - public async Task?> FindByLoginAsync( - string? tenantId, - string login, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - // InMemory limitation: scan profiles - if (_profiles is not InMemoryUserProfileStore mem) - throw new InvalidOperationException("InMemory only"); - - var profile = mem.AllProfiles - .FirstOrDefault(p => - !p.IsDeleted && - !string.IsNullOrWhiteSpace(p.Email) && - string.Equals(p.Email, login, StringComparison.OrdinalIgnoreCase)); - - if (profile is null) - return null; - - return await FindByIdAsync(tenantId, profile.UserKey, ct); - } - - public async Task ExistsAsync( - string? tenantId, - UserKey userId, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - throw new NotImplementedException(); - - //var lifecycle = await _lifecycle.GetAsync(tenantId, userId, ct); - //return lifecycle is not null && !lifecycle.IsDeleted; - } -} - diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs new file mode 100644 index 00000000..c35ef39c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class AddUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public AddUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs index 85720a7b..b29f57d5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs @@ -1,18 +1,19 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference { - internal sealed class CreateUserCommand : IAccessCommand + internal sealed class CreateUserCommand : IAccessCommand { - private readonly Func _execute; + private readonly Func> _execute; - public CreateUserCommand(Func execute) + public CreateUserCommand(Func> execute) { _execute = execute; } - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs deleted file mode 100644 index 068a1cd0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetCurrentUserProfileCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetCurrentUserProfileCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs new file mode 100644 index 00000000..813db3dd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class GetMeCommand : IAccessCommand +{ + private readonly Func> _execute; + + public GetMeCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs new file mode 100644 index 00000000..8da5b649 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class GetUserIdentifierCommand : IAccessCommand + { + private readonly Func> _execute; + + public GetUserIdentifierCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs index ddca8f73..b931025e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs @@ -5,15 +5,15 @@ namespace CodeBeam.UltimateAuth.Users.Reference { - internal sealed class GetUserIdentifiersCommand : IAccessCommand + internal sealed class GetUserIdentifiersCommand : IAccessCommand> { - private readonly Func> _execute; + private readonly Func>> _execute; - public GetUserIdentifiersCommand(Func> execute) + public GetUserIdentifiersCommand(Func>> execute) { _execute = execute; } - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs deleted file mode 100644 index 44d4bd40..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserProfileAdminCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetUserProfileAdminCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs new file mode 100644 index 00000000..4912a6b3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class GetUserProfileCommand : IAccessCommand +{ + private readonly Func> _execute; + + public GetUserProfileCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs new file mode 100644 index 00000000..800d6c0e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class SetPrimaryUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public SetPrimaryUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs new file mode 100644 index 00000000..f7f21b72 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class UnsetPrimaryUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public UnsetPrimaryUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs deleted file mode 100644 index 12b6cb87..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateCurrentUserProfileCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateCurrentUserProfileCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs new file mode 100644 index 00000000..d2521fad --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class UpdateUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public UpdateUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs new file mode 100644 index 00000000..b102c86b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UpdateUserProfileCommand : IAccessCommand +{ + private readonly Func _execute; + + public UpdateUserProfileCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs new file mode 100644 index 00000000..7cd93350 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CodeBeam.UltimateAuth.Users.Reference.Commands +{ + internal sealed class UserIdentifierExistsCommand : IAccessCommand + { + private readonly Func> _execute; + + public UserIdentifierExistsCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} 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..980c39f9 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public sealed class UserLifecycleQuery + { + public bool IncludeDeleted { get; init; } + public UserStatus? Status { get; init; } + + public int Skip { get; init; } + public int Take { get; init; } = 50; + } +} 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..9bc8a3ae --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public sealed class UserProfileQuery + { + public bool IncludeDeleted { get; init; } + + public int Skip { get; init; } + public int Take { get; init; } = 50; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs new file mode 100644 index 00000000..68f2948c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs @@ -0,0 +1,17 @@ +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed record UserProfileUpdate +{ + 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.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs new file mode 100644 index 00000000..96c2f27f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed record UserIdentifier +{ + public string? TenantId { get; set; } + + public UserKey UserKey { get; init; } + + public UserIdentifierType Type { get; init; } // Email, Phone, Username + public string Value { get; set; } = default!; + + public bool IsPrimary { get; set; } + public bool IsVerified { get; set; } + + public bool IsDeleted { get; set; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs deleted file mode 100644 index 292d1819..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public sealed record UserIdentifierRecord - { - public UserKey UserKey { get; init; } - - public UserIdentifierType Type { get; init; } // Email, Phone, Username - public string Value { get; init; } = default!; - - public bool IsPrimary { get; init; } - public bool IsVerified { get; init; } - - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? VerifiedAt { get; init; } - public DateTimeOffset? DeletedAt { get; init; } - } - -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs similarity index 51% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs rename to src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 0019d7de..017b25b5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -1,24 +1,20 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference.Domain; +namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed record class ReferenceUserProfile +public sealed record class UserLifecycle { - public UserKey UserKey { get; init; } = default!; + public string? TenantId { get; set; } - public string? FirstName { get; set; } - public string? LastName { get; set; } - public string? DisplayName { get; set; } - public string? Email { get; init; } - public string? Phone { get; init; } + public UserKey UserKey { get; init; } = default!; public UserStatus Status { get; set; } = UserStatus.Active; + public Guid SecurityStamp { get; set; } public bool IsDeleted { get; set; } + public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? UpdatedAt { get; set; } public DateTimeOffset? DeletedAt { get; set; } - - public IReadOnlyDictionary? Metadata { get; init; } } 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..b8cb4a70 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +// TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) +public sealed record class UserProfile +{ + public string? TenantId { get; set; } + + public UserKey UserKey { get; init; } = 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 IReadOnlyDictionary? Metadata { get; set; } + + public bool IsDeleted { get; set; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs new file mode 100644 index 00000000..87b99025 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs @@ -0,0 +1,419 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +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 DefaultUserEndpointHandler : IUserEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUserApplicationService _users; + + public DefaultUserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserApplicationService users) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _users = users; + } + + public async Task CreateAsync(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.Create, + 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 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 accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + var result = await _users.GetIdentifiersByUserAsync(accessContext,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 accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetAdmin, + resource: "users", + resourceId: userKey.Value); + + var result = await _users.GetIdentifiersByUserAsync(accessContext, ctx.RequestAborted); + + return Results.Ok(result); + } + + 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/Endpoints/DefaultUserLifecycleEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs deleted file mode 100644 index 94019c43..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs +++ /dev/null @@ -1,96 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; -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 DefaultUserLifecycleEndpointHandler : IUserLifecycleEndpointHandler - { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserLifecycleService _lifecycle; - - public DefaultUserLifecycleEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserLifecycleService lifecycle) - { - _authFlow = authFlow; - _accessContextFactory = accessContextFactory; - _lifecycle = lifecycle; - } - - public async Task CreateAsync(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.Create, - resource: "users" - ); - - var result = await _lifecycle.CreateAsync(accessContext, request, ctx.RequestAborted); - - return result.Succeeded - ? Results.Ok(result) - : Results.BadRequest(result); - } - - public async Task ChangeStatusAsync(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.ChangeStatus, - resource: "users", - resourceId: request.UserKey.Value - ); - - var result = await _lifecycle.ChangeStatusAsync(accessContext, request, ctx.RequestAborted); - - return result.Succeeded - ? Results.Ok(result) - : Results.BadRequest(result); - } - - public async Task DeleteAsync(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.Delete, - resource: "users", - resourceId: request.UserKey.Value, - attributes: new Dictionary - { - ["deleteMode"] = request.Mode - } - ); - - var result = await _lifecycle.DeleteAsync(accessContext, request, ctx.RequestAborted); - - return result.Succeeded - ? Results.Ok(result) - : Results.BadRequest(result); - } - - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs deleted file mode 100644 index dd7816cc..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs +++ /dev/null @@ -1,61 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; -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 DefaultUserProfileAdminEndpointHandler : IUserProfileAdminEndpointHandler -{ - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserProfileAdminService _profiles; - - public DefaultUserProfileAdminEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserProfileAdminService profiles) - { - _authFlow = authFlow; - _accessContextFactory = accessContextFactory; - _profiles = profiles; - } - - public async Task GetAsync(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 result = await _profiles.GetAsync(accessContext, userKey, ctx.RequestAborted); - return Results.Ok(result); - } - - public async Task UpdateAsync(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 _profiles.UpdateAsync(accessContext, userKey, request, ctx.RequestAborted); - return Results.NoContent(); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs deleted file mode 100644 index 92093274..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs +++ /dev/null @@ -1,62 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; -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 DefaultUserProfileEndpointHandler : IUserProfileEndpointHandler - { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAccessContextFactory _accessContextFactory; - private readonly IUAuthUserProfileService _profiles; - - public DefaultUserProfileEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUAuthUserProfileService profiles) - { - _authFlow = authFlow; - _accessContextFactory = accessContextFactory; - _profiles = profiles; - } - - public async Task GetAsync(HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated || flow.UserKey is null) - return Results.Unauthorized(); - - var accessContext = await _accessContextFactory.CreateAsync( - authFlow: flow, - action: UAuthActions.UserProfiles.GetSelf, - resource: "users", - resourceId: flow.UserKey.Value - ); - - var result = await _profiles.GetCurrentAsync(accessContext, ctx.RequestAborted); - return Results.Ok(result); - } - - public async Task UpdateAsync(HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated || flow.UserKey is null) - 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 _profiles.UpdateCurrentAsync(accessContext, request, ctx.RequestAborted); - return Results.NoContent(); - } - - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index f2f1edd4..30df9de9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -1,7 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Reference; -using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -16,13 +14,9 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl // Marker only – runtime validation happens via DI resolution }); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs index e1184ec1..ccfee913 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference { public static class UserIdentifierMapper { - public static UserIdentifierDto ToDto(UserIdentifierRecord record) + public static UserIdentifierDto ToDto(UserIdentifier record) => new() { Type = record.Type, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index 594c63d1..116083b5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -1,20 +1,36 @@ using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Domain; namespace CodeBeam.UltimateAuth.Users.Reference; internal static class UserProfileMapper { - public static UserProfileDto ToDto(ReferenceUserProfile profile) + public static UserViewDto ToDto(UserProfile profile) => new() { UserKey = profile.UserKey.ToString(), FirstName = profile.FirstName, LastName = profile.LastName, DisplayName = profile.DisplayName, - Email = profile.Email, - Phone = profile.Phone, - Status = profile.Status, + Bio = profile.Bio, + BirthDate = profile.BirthDate, + CreatedAt = profile.CreatedAt, + Gender = profile.Gender, Metadata = profile.Metadata }; + + public static UserProfileUpdate ToUpdate(UpdateProfileRequest request) + => new() + { + FirstName = request.FirstName, + LastName = request.LastName, + DisplayName = request.DisplayName, + BirthDate = request.BirthDate, + Gender = request.Gender, + Bio = request.Bio, + Language = request.Language, + TimeZone = request.TimeZone, + Culture = request.Culture, + Metadata = request.Metadata + }; + } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs deleted file mode 100644 index 64b92e1c..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs +++ /dev/null @@ -1,126 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Users.Contracts; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DefaultUserIdentifierService : IUserIdentifierService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserIdentifierStore _store; - private readonly UAuthServerOptions _serverOptions; - private readonly IClock _clock; - - public DefaultUserIdentifierService(IAccessOrchestrator accessOrchestrator, IUserIdentifierStore store, IOptions serverOptions, IClock clock) - { - _accessOrchestrator = accessOrchestrator; - _store = store; - _serverOptions = serverOptions.Value; - _clock = clock; - } - - public async Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new GetUserIdentifiersCommand( - async innerCt => - { - var records = await _store.GetAllAsync( - context.ResourceTenantId, - targetUserKey, - innerCt); - - var dtos = records - .Where(r => r.DeletedAt is null) - .Select(UserIdentifierMapper.ToDto) - .ToArray(); - - return new GetUserIdentifiersResult - { - Identifiers = dtos - }; - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task ChangeAsync(AccessContext context, UserKey targetUserKey, ChangeUserIdentifierRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var record = new UserIdentifierRecord - { - Type = request.Type, - Value = request.NewValue, - IsVerified = false, - CreatedAt = _clock.UtcNow, - VerifiedAt = null, - DeletedAt = null - }; - - var cmd = new ChangeUserIdentifierCommand( - async innerCt => - { - var exists = await _store.ExistsAsync(context.ResourceTenantId, request.Type, request.NewValue, innerCt); - - if (exists) - throw new InvalidOperationException("identifier_already_exists"); - - await _store.SetAsync(context.ResourceTenantId, targetUserKey, record, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - - return IdentifierChangeResult.Success(); - } - - public async Task VerifyAsync(AccessContext context, UserKey targetUserKey, VerifyUserIdentifierRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new VerifyUserIdentifierCommand( - async innerCt => - { - await _store.MarkVerifiedAsync(context.ResourceTenantId, targetUserKey, request.Type, _clock.UtcNow, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - - return IdentifierVerificationResult.Success(); - } - - public async Task DeleteAsync(AccessContext context, UserKey targetUserKey, DeleteUserIdentifierRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new DeleteUserIdentifierCommand( - async innerCt => - { - var identifiers = await _store.GetByTypeAsync(context.ResourceTenantId, targetUserKey, request.Type, innerCt); - - var activeCount = identifiers.Count(i => i.DeletedAt is null); - - if (activeCount <= 1 && request.Type == UserIdentifierType.Username) - throw new InvalidOperationException("last_username_cannot_be_deleted"); - - await _store.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Type, request.Value, request.Mode, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - - return IdentifierDeleteResult.Success(); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs deleted file mode 100644 index 325fd0c0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs +++ /dev/null @@ -1,184 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Credentials.Reference; -using CodeBeam.UltimateAuth.Credentials.Reference.Internal; -using CodeBeam.UltimateAuth.Server.Defaults; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Domain; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class DefaultUserLifecycleService : IUserLifecycleService - { - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserStore _users; - private readonly IUserProfileStore _profiles; - private readonly IUserLifecycleStore _userLifecycleStore; - private readonly IUserCredentialsService _credentials; - private readonly IUserCredentialsInternalService _credentialsInternal; - private readonly IUserIdentifierService _identifierService; - private readonly ISessionService _sessionService; - private readonly IAuthContextFactory _authContextFactory; - private readonly IClock _clock; - - public DefaultUserLifecycleService( - IAccessOrchestrator accessOrchestrator, - IUserStore users, - IUserProfileStore profiles, - IUserLifecycleStore userLifecycleStore, - IUserCredentialsService credentials, - IUserCredentialsInternalService credentialsInternal, - IUserIdentifierService identifierService, - ISessionService sessionService, - IAuthContextFactory authContextFactory, - IClock clock) - { - _accessOrchestrator = accessOrchestrator; - _users = users; - _profiles = profiles; - _userLifecycleStore = userLifecycleStore; - _credentials = credentials; - _credentialsInternal = credentialsInternal; - _identifierService = identifierService; - _sessionService = sessionService; - _authContextFactory = authContextFactory; - _clock = clock; - } - - public async Task CreateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(request.Identifier)) - return UserCreateResult.Failed("identifier_required"); - - var policies = Array.Empty(); - var userKey = UserKey.New(); - var now = _clock.UtcNow; - - var cmd = new CreateUserCommand( - async innerCt => - { - var existing = await _users.FindByLoginAsync(context.ResourceTenantId, request.Identifier, innerCt); - - if (existing is not null) - throw new InvalidOperationException("user_already_exists"); - - var profile = new ReferenceUserProfile - { - UserKey = userKey, - Email = request.Identifier, - DisplayName = request.DisplayName, - Status = UserStatus.Active, - IsDeleted = false, - CreatedAt = now, - UpdatedAt = now, - DeletedAt = null, - FirstName = request.Profile?.FirstName, - LastName = request.Profile?.LastName, - }; - - await _userLifecycleStore.CreateAsync(context.ResourceTenantId, profile, innerCt); - await _profiles.CreateAsync(context.ResourceTenantId, profile, innerCt); - - if (!string.IsNullOrWhiteSpace(request.Password)) - { - await _credentials.AddAsync( - new AccessContext - { - ActorUserKey = context.ActorUserKey, - ActorTenantId = context.ActorTenantId, - IsAuthenticated = context.IsAuthenticated, - - Resource = "credentials", - ResourceId = userKey.Value, - ResourceTenantId = context.ResourceTenantId, - - Action = UAuthActions.Credentials.Add - }, - new AddCredentialRequest - { - Type = CredentialType.Password, - Secret = request.Password - }, - innerCt); - - } - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - return UserCreateResult.Success(userKey); - } - - public async Task ChangeStatusAsync(AccessContext context, ChangeUserStatusRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - UserStatus oldStatus = default; - - var cmd = new ChangeUserStatusCommand( - async innerCt => - { - var profile = await _profiles.GetAsync(context.ResourceTenantId, request.UserKey, innerCt); - - if (profile is null) - throw new InvalidOperationException("user_not_found"); - - if (profile.Status == request.NewStatus) - return; - - oldStatus = profile.Status; - //await _profiles.SetStatusAsync(context.ResourceTenantId, request.UserKey, request.NewStatus, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - return UserStatusChangeResult.Success(oldStatus, request.NewStatus); - } - - public async Task DeleteAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new DeleteUserCommand( - async innerCt => - { - var user = await _users.FindByIdAsync(context.ResourceTenantId, request.UserKey, innerCt); - - if (user is null) - throw new InvalidOperationException("user_not_found"); - - var authContext = _authContextFactory.Create(); - if (request.Mode == DeleteMode.Soft) - { - if (user.IsDeleted) - return; - - await _userLifecycleStore.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Soft, _clock.UtcNow, innerCt); - await _sessionService.RevokeAllAsync(authContext, request.UserKey,innerCt); - return; - } - - // Hard delete - if (!user.IsDeleted) - { - await _userLifecycleStore.DeleteAsync(context.ResourceTenantId,request.UserKey, DeleteMode.Soft, _clock.UtcNow, innerCt); - } - - await _sessionService.RevokeAllAsync(authContext, request.UserKey, innerCt); - await _credentialsInternal.DeleteInternalAsync(context.ResourceTenantId, request.UserKey, innerCt); - await _profiles.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Hard, innerCt); - await _userLifecycleStore.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Hard, _clock.UtcNow, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - return UserDeleteResult.Success(request.Mode); - } - - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs deleted file mode 100644 index c01fb80f..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs +++ /dev/null @@ -1,54 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DefaultUserProfileAdminService : IUserProfileAdminService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserProfileStore _profiles; - - public DefaultUserProfileAdminService(IAccessOrchestrator accessOrchestrator, IUserProfileStore profiles) - { - _accessOrchestrator = accessOrchestrator; - _profiles = profiles; - } - - public async Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new GetUserProfileAdminCommand( - async innerCt => - { - var profile = await _profiles.GetAsync(context.ResourceTenantId, targetUserKey, innerCt); - - if (profile is null) - throw new InvalidOperationException("user_profile_not_found"); - - return UserProfileMapper.ToDto(profile); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task UpdateAsync(AccessContext context, UserKey targetUserKey, UpdateProfileRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new UpdateUserProfileAdminCommand( - async innerCt => - { - await _profiles.UpdateAsync(context.ResourceTenantId, targetUserKey, request, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs deleted file mode 100644 index af43cbb7..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs +++ /dev/null @@ -1,60 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DefaultUserProfileService : IUAuthUserProfileService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserProfileStore _profiles; - - public DefaultUserProfileService(IAccessOrchestrator accessOrchestrator, IUserProfileStore profiles) - { - _accessOrchestrator = accessOrchestrator; - _profiles = profiles; - } - - public async Task GetCurrentAsync(AccessContext context, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new GetCurrentUserProfileCommand( - async innerCt => - { - if (context.ActorUserKey is null) - throw new UnauthorizedAccessException(); - - var profile = await _profiles.GetAsync(context.ResourceTenantId, (UserKey)context.ActorUserKey, innerCt); - - if (profile is null) - throw new InvalidOperationException("user_profile_not_found"); - - return UserProfileMapper.ToDto(profile); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task UpdateCurrentAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new UpdateCurrentUserProfileCommand( - async innerCt => - { - if (context.ActorUserKey is null) - throw new UnauthorizedAccessException(); - - await _profiles.UpdateAsync(context.ResourceTenantId, (UserKey)context.ActorUserKey, request, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } -} 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..494ad49c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -0,0 +1,37 @@ +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 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, CancellationToken ct = default); + + Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); + + Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, 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 DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs deleted file mode 100644 index d70e1a54..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public interface IUserIdentifierService - { - Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); - Task ChangeAsync(AccessContext context, UserKey targetUserKey, ChangeUserIdentifierRequest request, CancellationToken ct = default); - Task VerifyAsync(AccessContext context, UserKey targetUserKey, VerifyUserIdentifierRequest request, CancellationToken ct = default); - Task DeleteAsync(AccessContext context, UserKey targetUserKey, DeleteUserIdentifierRequest request, CancellationToken ct = default); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs deleted file mode 100644 index c416e87c..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -public interface IUserLifecycleService -{ - Task CreateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); - Task ChangeStatusAsync(AccessContext context, ChangeUserStatusRequest request, CancellationToken ct = default); - Task DeleteAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs deleted file mode 100644 index 5b8f402d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public interface IUserProfileAdminService - { - Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); - Task UpdateAsync(AccessContext context, UserKey targetUserKey, UpdateProfileRequest request, CancellationToken ct = default); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs deleted file mode 100644 index 1c968332..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public interface IUAuthUserProfileService - { - Task GetCurrentAsync(AccessContext context, CancellationToken ct = default); - Task UpdateCurrentAsync(AccessContext context, UpdateProfileRequest 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..8d396f83 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -0,0 +1,377 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Commands; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserApplicationService : IUserApplicationService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserLifecycleStore _lifecycleStore; + private readonly IUserProfileStore _profileStore; + private readonly IUserIdentifierStore _identifierStore; + private readonly IEnumerable _integrations; + private readonly IClock _clock; + + public UserApplicationService( + IAccessOrchestrator accessOrchestrator, + IUserLifecycleStore lifecycleStore, + IUserProfileStore profileStore, + IUserIdentifierStore identifierStore, + IEnumerable integrations, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _lifecycleStore = lifecycleStore; + _profileStore = profileStore; + _identifierStore = identifierStore; + _integrations = integrations; + _clock = clock; + } + + public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) + { + var command = new GetMeCommand(async innerCt => + { + if (context.ActorUserKey is null) + throw new UnauthorizedAccessException(); + + return await BuildUserViewAsync(context.ResourceTenantId, context.ActorUserKey.Value, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) + { + var command = new GetUserProfileCommand(async innerCt => + { + // Target user MUST exist in context + var targetUserKey = context.GetTargetUserKey(); + + return await BuildUserViewAsync(context.ResourceTenantId, targetUserKey, innerCt); + + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + { + var command = new CreateUserCommand(async innerCt => + { + var now = _clock.UtcNow; + var userKey = UserKey.New(); + + if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is null) + { + return UserCreateResult.Failed("primary_identifier_type_required"); + } + + await _lifecycleStore.CreateAsync(context.ResourceTenantId, + new UserLifecycle + { + UserKey = userKey, + Status = UserStatus.Active, + CreatedAt = now + }, + innerCt); + + await _profileStore.CreateAsync(context.ResourceTenantId, + new UserProfile + { + UserKey = userKey, + FirstName = request.FirstName, + LastName = request.LastName, + DisplayName = request.DisplayName, + BirthDate = request.BirthDate, + Gender = request.Gender, + Bio = request.Bio, + Language = request.Language, + TimeZone = request.TimeZone, + Culture = request.Culture, + Metadata = request.Metadata, + CreatedAt = now + }, + innerCt); + + if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is not null) + { + await _identifierStore.CreateAsync(context.ResourceTenantId, + new UserIdentifier + { + UserKey = userKey, + Type = request.PrimaryIdentifierType.Value, + Value = request.PrimaryIdentifierValue, + IsPrimary = true, + IsVerified = request.PrimaryIdentifierVerified, + CreatedAt = now, + VerifiedAt = request.PrimaryIdentifierVerified ? now : null + }, + innerCt); + } + + foreach (var integration in _integrations) + { + await integration.OnUserCreatedAsync(context.ResourceTenantId, 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 ChangeUserStatusCommand(async innerCt => + { + var newStatus = request switch + { + ChangeUserStatusSelfRequest r => r.NewStatus, + ChangeUserStatusAdminRequest r => r.NewStatus, + _ => throw new InvalidOperationException("invalid_request") + }; + + var targetUserKey = context.GetTargetUserKey(); + var current = await _lifecycleStore.GetAsync(context.ResourceTenantId, targetUserKey, innerCt); + + if (current is null) + throw new InvalidOperationException("user_not_found"); + + if (context.IsSelfAction && !IsSelfTransitionAllowed(current.Status, newStatus)) + throw new InvalidOperationException("self_transition_not_allowed"); + + if (!context.IsSelfAction) + { + if (newStatus is UserStatus.SelfSuspended or UserStatus.Deactivated) + throw new InvalidOperationException("admin_cannot_set_self_status"); + } + + await _lifecycleStore.ChangeStatusAsync(context.ResourceTenantId, targetUserKey, newStatus, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) + { + var command = new UpdateUserProfileCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + var update = UserProfileMapper.ToUpdate(request); + + await _profileStore.UpdateAsync(context.ResourceTenantId, targetUserKey, update, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default) + { + var command = new GetUserIdentifiersCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, targetUserKey, innerCt); + + return identifiers.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) + { + var command = new GetUserIdentifierCommand(async innerCt => + { + var identifier = await _identifierStore.GetAsync(context.ResourceTenantId, type, value, 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, CancellationToken ct = default) + { + var command = new UserIdentifierExistsCommand(async innerCt => + { + return await _identifierStore.ExistsAsync(context.ResourceTenantId, type, value, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new AddUserIdentifierCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + await _identifierStore.CreateAsync(context.ResourceTenantId, + new UserIdentifier + { + UserKey = userKey, + Type = request.Type, + Value = request.Value, + IsPrimary = request.IsPrimary, + IsVerified = false, + CreatedAt = _clock.UtcNow + }, + innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new UpdateUserIdentifierCommand(async innerCt => + { + if (string.Equals(request.OldValue, request.NewValue, StringComparison.Ordinal)) + throw new InvalidOperationException("identifier_value_unchanged"); + + await _identifierStore.UpdateValueAsync(context.ResourceTenantId, request.Type, request.OldValue, request.NewValue, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new SetPrimaryUserIdentifierCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + await _identifierStore.SetPrimaryAsync(context.ResourceTenantId, userKey, request.Type, request.Value, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new UnsetPrimaryUserIdentifierCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, userKey, innerCt); + var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); + + if (target is null) + throw new InvalidOperationException("identifier_not_found"); + + if (!target.IsPrimary) + throw new InvalidOperationException("identifier_not_primary"); + + var otherLoginIdentifiers = identifiers.Where(i => !i.IsDeleted && IsLoginIdentifier(i.Type) && !(i.Type == target.Type && i.Value == target.Value)).ToList(); + if (otherLoginIdentifiers.Count == 0) + throw new InvalidOperationException("cannot_unset_last_primary_login_identifier"); + + await _identifierStore.UnsetPrimaryAsync(context.ResourceTenantId, userKey, target.Type, target.Value, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new VerifyUserIdentifierCommand(async innerCt => + { + await _identifierStore.MarkVerifiedAsync(context.ResourceTenantId, request.Type, request.Value, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new DeleteUserIdentifierCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, targetUserKey, innerCt); + var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); + + if (target is null) + throw new InvalidOperationException("identifier_not_found"); + + var loginIdentifiers = identifiers.Where(i => !i.IsDeleted && IsLoginIdentifier(i.Type)).ToList(); + if (IsLoginIdentifier(target.Type) && loginIdentifiers.Count == 1) + throw new InvalidOperationException("cannot_delete_last_login_identifier"); + + if (target.IsPrimary) + throw new InvalidOperationException("cannot_delete_primary_identifier"); + + await _identifierStore.DeleteAsync(context.ResourceTenantId, request.Type, request.Value, request.Mode, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) + { + var command = new DeleteUserCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + await _lifecycleStore.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); + await _identifierStore.DeleteByUserAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); + await _profileStore.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); + + foreach (var integration in _integrations) + { + await integration.OnUserDeletedAsync(context.ResourceTenantId, targetUserKey, request.Mode, innerCt); + } + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + private async Task BuildUserViewAsync(string? tenantId, UserKey userKey, CancellationToken ct) + { + var profile = await _profileStore.GetAsync(tenantId, userKey, ct); + + if (profile is null || profile.IsDeleted) + throw new InvalidOperationException("user_profile_not_found"); + + var identifiers = await _identifierStore.GetByUserAsync(tenantId, 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 + }; + } + + private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) + => (from, to) switch + { + (UserStatus.Active, UserStatus.SelfSuspended) => true, + (UserStatus.SelfSuspended, UserStatus.Active) => true, + (UserStatus.Active or UserStatus.SelfSuspended, UserStatus.Deactivated) => true, + _ => false + }; + + private static bool IsLoginIdentifier(UserIdentifierType type) + => type is + UserIdentifierType.Username or + UserIdentifierType.Email or + UserIdentifierType.Phone; + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index f07c2dca..71a42be7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -2,15 +2,27 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserIdentifierStore { - public interface IUserIdentifierStore - { - Task> GetAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); - Task> GetByTypeAsync(string? tenantId, UserKey userKey, UserIdentifierType type, CancellationToken ct = default); - Task SetAsync(string? tenantId, UserKey userKey, UserIdentifierRecord record, CancellationToken ct = default); - Task MarkVerifiedAsync(string? tenantId, UserKey userKey, UserIdentifierType type, DateTimeOffset verifiedAt, CancellationToken ct = default); - Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, DeleteMode mode, CancellationToken ct = default); - } + Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); + + Task> GetByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task GetAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); + + Task CreateAsync(string? tenantId, UserIdentifier identifier, CancellationToken ct = default); + + Task UpdateValueAsync(string? tenantId, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); + + Task MarkVerifiedAsync(string? tenantId, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default); + + Task SetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); + + Task UnsetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); + + Task DeleteAsync(string? tenantId, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + + Task DeleteByUserAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs index 15f279e3..44507c80 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -1,14 +1,23 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Domain; namespace CodeBeam.UltimateAuth.Users.Reference { public interface IUserLifecycleStore { - Task CreateAsync(string? tenantId, ReferenceUserProfile user, CancellationToken ct = default); - Task UpdateStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset at, CancellationToken ct = default); + Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task> QueryAsync(string? tenantId, UserLifecycleQuery query, CancellationToken ct = default); + + Task CreateAsync(string? tenantId, UserLifecycle lifecycle, CancellationToken ct = default); + + Task ChangeStatusAsync(string? tenantId, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default); + + Task ChangeSecurityStampAsync(string? tenantId, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default); + + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index c2f300da..78502085 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,15 +1,19 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Domain; namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserProfileStore { - // TODO: Do CreateAsync internal with initializer service - Task CreateAsync(string? tenantId, ReferenceUserProfile profile, CancellationToken ct = default); - Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); - Task UpdateAsync(string? tenantId, UserKey userKey, UpdateProfileRequest request, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default); + Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task> QueryAsync(string? tenantId, UserProfileQuery query, CancellationToken ct = default); + + Task CreateAsync(string? tenantId, UserProfile profile, CancellationToken ct = default); + + Task UpdateAsync(string? tenantId, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default); + + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs new file mode 100644 index 00000000..8c02456a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class UserRuntimeStore : IUserRuntimeStateProvider + { + private readonly IUserLifecycleStore _lifecycleStore; + + public UserRuntimeStore(IUserLifecycleStore lifecycleStore) + { + _lifecycleStore = lifecycleStore; + } + + public async Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + var lifecycle = await _lifecycleStore.GetAsync(tenantId, userKey, ct); + + if (lifecycle is null) + return null; + + return new UserRuntimeRecord + { + UserKey = lifecycle.UserKey, + IsActive = lifecycle.Status == UserStatus.Active, + IsDeleted = lifecycle.IsDeleted, + Exists = true + }; + } + } +} 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..130390a5 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Abstractions; + +/// +/// Optional integration point for reacting to user lifecycle events. +/// Implemented by plugin domains (Credentials, Authorization, Audit, etc). +/// +public interface IUserLifecycleIntegration +{ + Task OnUserCreatedAsync(string? tenantId, UserKey userKey, object request, CancellationToken ct = default); + + Task OnUserDeletedAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs deleted file mode 100644 index 1534c020..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users -{ - public interface IUserStore - { - /// - /// Finds a user by its application-level user id. - /// Returns null if the user does not exist or is deleted. - /// - Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default); - - /// - /// Finds a user by a login identifier (username, email, etc). - /// Used during login discovery phase. - /// - Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default); - - /// - /// Checks whether a user exists. - /// Fast-path helper for authorities. - /// - Task ExistsAsync(string? tenantId, TUserId userId, CancellationToken ct = default); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj index 1f3e2def..aacabccb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj @@ -10,6 +10,7 @@ + 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..ff7c37b5 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Policies +{ + public class ActionTextTests + { + [Theory] + [InlineData("users.profile.get.admin", true)] + [InlineData("users.profile.get.self", false)] + [InlineData("users.profile.get", false)] + public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) + { + var context = new AccessContext { Action = action }; + var policy = new RequireAdminPolicy(); + + Assert.Equal(expected, policy.AppliesTo(context)); + } + + [Fact] + public void RequireAdminPolicy_DoesNotMatch_Substrings() + { + var context = new AccessContext + { + Action = "users.profile.get.administrator" + }; + + var policy = new RequireAdminPolicy(); + + Assert.False(policy.AppliesTo(context)); + } + + } +} From a6955d9e29b8880cdf77389e12231138f9fc7856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 1 Feb 2026 01:21:38 +0300 Subject: [PATCH 28/50] Update project status in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c8dd1aa1..84af7abe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![UltimateAuth Banner](https://github.com/user-attachments/assets/4204666e-b57a-4cb5-8846-dc7e4f16bfe9) -⚠️ This project is in development. First preview release expected Q1 2026 - coming soon. +⚠️ UltimateAuth is under active development. Core architecture and public APIs are now in place. The first release (v 0.0.1) is expected within days. ![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) From cd6331b6bdef8c7b73577848c7ab7a7afb1fc1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:29:18 +0300 Subject: [PATCH 29/50] Code Refactoring & Polishing (#16) * Code Refactoring & Polishing * Core Project Cleanup * Remove Base64Url Duplicated Methods * Remove Session Domain Interfaces & EFCore Session Multi Tenant Enhancements * Added ISessionValidator * Added TenantKey Value Object * Refactoring on Credential Projects * Main Refactoring * Fix Compiler Warnings & Last Refactorings --- .../Components/Pages/Home.razor.cs | 8 +- .../Controllers/HubLoginController.cs | 3 +- .../Components/Pages/Home.razor | 2 +- .../Program.cs | 7 +- .../Pages/Home.razor.cs | 2 +- .../Abstractions/IBrowserStorage.cs | 15 +- .../Abstractions/ISessionCoordinator.cs | 27 +- .../DefaultUAuthStateManager.cs | 55 --- .../Authentication/IUAuthStateManager.cs | 55 ++- .../UAuthAuthenticatonStateProvider.cs | 33 +- .../UAuthCascadingStateProvider.cs | 35 +- .../Authentication/UAuthState.cs | 167 ++++---- .../Authentication/UAuthStateChangeReason.cs | 15 +- .../Authentication/UAuthStateManager.cs | 54 +++ .../Components/UALoginForm.razor.cs | 224 +++++----- .../UAuthAuthenticationState.razor.cs | 61 ++- .../Components/UAuthClientProvider.razor.cs | 74 ++-- .../Contracts/CoordinatorTerminationReason.cs | 11 +- .../Contracts/PkceClientState.cs | 11 +- .../Contracts/RefreshResult.cs | 13 +- .../Contracts/StorageScope.cs | 11 +- .../Contracts/UAuthTransportResult.cs | 15 +- .../Device/IDeviceIdGenerator.cs | 9 +- .../Device/IDeviceIdProvider.cs | 9 +- .../Device/IDeviceIdStorage.cs | 11 +- ...Generator.cs => UAuthDeviceIdGenerator.cs} | 2 +- ...IdProvider.cs => UAuthDeviceIdProvider.cs} | 4 +- .../Extensions/LoginRequestFormExtensions.cs | 20 +- .../Extensions/ServiceCollectionExtensions.cs | 138 +++++++ ...teAuthClientServiceCollectionExtensions.cs | 138 ------- .../BlazorServerSessionCoordinator.cs | 151 ++++--- .../Infrastructure/BrowserStorage.cs | 37 +- .../Infrastructure/IUAuthRequestClient.cs | 15 +- .../Infrastructure/NoOpHubCapabilities.cs | 9 +- .../NoOpHubCredentialResolver.cs | 9 +- .../Infrastructure/NoOpHubFlowReader.cs | 9 +- .../Infrastructure/NoOpSessionCoordinator.cs | 22 +- .../Infrastructure/RefreshOutcomeParser.cs | 27 +- .../Infrastructure/UAuthRequestClient.cs | 105 +++-- .../Infrastructure/UAuthResultMapper.cs | 63 ++- .../Infrastructure/UAuthUrlBuilder.cs | 11 +- .../Options/PkceLoginOptions.cs | 38 +- .../Options/UAuthClientOptions.cs | 123 +++--- .../Options/UAuthClientProfileDetector.cs | 37 +- .../Options/UAuthOptionsPostConfigure.cs | 35 +- .../ProductInfo/UAuthClientProductInfo.cs | 11 +- .../Runtime/IUAuthClientBootstrapper.cs | 9 +- .../Runtime/UAuthClientBootstrapper.cs | 71 ++-- .../Services/DefaultAuthorizationClient.cs | 65 --- .../Services/DefaultCredentialClient.cs | 103 ----- .../Services/DefaultFlowClient.cs | 221 ---------- .../Services/DefaultUserClient.cs | 77 ---- .../Services/DefaultUserIdentifierClient.cs | 120 ------ .../Services/IAuthorizationClient.cs | 17 +- .../Services/ICredentialClient.cs | 33 +- .../Services/IUAuthClient.cs | 17 +- .../Services/IUserClient.cs | 23 +- .../Services/IUserIdentifierClient.cs | 35 +- .../Services/UAuthAuthorizationClient.cs | 64 +++ .../Services/UAuthCredentialClient.cs | 102 +++++ .../Services/UAuthFlowClient.cs | 212 ++++++++++ .../Services/UAuthUserClient.cs | 76 ++++ .../Services/UAuthUserIdentifierClient.cs | 119 ++++++ .../Storage/.gitkeep | 1 - .../Authority/IAccessAuthority.cs | 10 +- .../Authority/IAccessInvariant.cs | 9 +- .../Abstractions/Authority/IAccessPolicy.cs | 11 +- .../Abstractions/Authority/IAuthAuthority.cs | 9 +- .../Authority/IAuthorityInvariant.cs | 9 +- .../Authority/IAuthorityPolicy.cs | 11 +- .../Abstractions/Hub/IHubCapabilities.cs | 9 +- .../Hub/IHubCredentialResolver.cs | 9 +- .../Abstractions/Hub/IHubFlowReader.cs | 9 +- .../Abstractions/Infrastructure/IClock.cs | 17 +- .../Infrastructure/ISeedContributor.cs | 6 +- .../Infrastructure/ITokenHasher.cs | 19 +- .../Infrastructure/IUAuthPasswordHasher.cs | 21 +- .../Issuers/IJwtTokenGenerator.cs | 17 +- .../Issuers/IOpaqueTokenGenerator.cs | 17 +- .../Abstractions/Issuers/ISessionIssuer.cs | 20 +- .../Principals/IUserClaimsProvider.cs | 3 +- .../Principals/IUserIdConverter.cs | 83 ++-- .../Principals/IUserIdConverterResolver.cs | 37 +- .../Abstractions/Principals/IUserIdFactory.cs | 23 +- .../Abstractions/Services/ISessionService.cs | 12 - .../Abstractions/Services/IUAuthService.cs | 15 - .../Services/IUAuthSessionManager.cs | 40 +- .../Services/IUAuthUserService.cs | 15 - .../Stores/DefaultSessionStoreFactory.cs | 20 - .../Stores/IAccessTokenIdStore.cs | 27 +- .../Abstractions/Stores/IRefreshTokenStore.cs | 13 +- .../Abstractions/Stores/ISessionStore.cs | 46 --- .../Stores/ISessionStoreKernel.cs | 48 +-- .../Stores/ISessionStoreKernelFactory.cs | 32 +- .../Stores/ITenantAwareSessionStore.cs | 7 - .../Abstractions/Stores/IUAuthUserStore.cs | 52 --- .../Abstractions/Stores/IUserStoreFactory.cs | 26 -- .../User/IUserRuntimeStateProvider.cs | 3 +- .../Abstractions/Validators/IJwtValidator.cs | 17 +- .../AssemblyVisibility.cs | 1 + .../Contracts/Authority/AccessContext.cs | 90 ++--- .../Contracts/Authority/AccessDecision.cs | 62 ++- .../Authority/AccessDecisionResult.cs | 41 +- .../Contracts/Authority/AuthContext.cs | 20 +- .../Contracts/Authority/AuthOperation.cs | 19 +- .../Authority/AuthorizationDecision.cs | 14 +- .../Authority/DeviceMismatchBehavior.cs | 14 +- .../Contracts/Common/DeleteMode.cs | 11 +- .../Contracts/Common/PagedResult.cs | 19 +- .../Contracts/Common/UAuthResult.cs | 28 +- .../Contracts/Login/ExternalLoginRequest.cs | 18 +- .../Contracts/Login/LoginContinuation.cs | 31 +- .../Contracts/Login/LoginContinuationType.cs | 13 +- .../Contracts/Login/LoginRequest.cs | 34 +- .../Contracts/Login/LoginResult.cs | 71 ++-- .../Contracts/Login/LoginStatus.cs | 13 +- .../Contracts/Login/ReauthRequest.cs | 14 +- .../Contracts/Login/ReauthResult.cs | 9 +- .../Contracts/Login/UAuthLoginType.cs | 11 +- .../Contracts/Logout/LogoutAllRequest.cs | 31 +- .../Contracts/Logout/LogoutRequest.cs | 15 +- .../Contracts/Logout/LogoutResponse.cs | 6 + .../Contracts/Mfa/BeginMfaRequest.cs | 9 +- .../Contracts/Mfa/CompleteMfaRequest.cs | 11 +- .../Contracts/Mfa/MfaChallengeResult.cs | 11 +- .../Contracts/Pkce/PkceCompleteRequest.cs | 17 +- .../Contracts/Pkce/PkceLoginRequest.cs | 6 +- .../Contracts/Refresh/RefreshFlowRequest.cs | 17 +- .../Contracts/Refresh/RefreshFlowResult.cs | 59 ++- .../Contracts/Refresh/RefreshStrategy.cs | 17 +- .../Refresh/RefreshTokenPersistence.cs | 29 +- .../Refresh/RefreshTokenValidationContext.cs | 19 +- .../Contracts/Session/AuthStateSnapshot.cs | 18 +- .../Contracts/Session/AuthValidationResult.cs | 37 +- .../Session/AuthenticatedSessionContext.cs | 48 +-- .../Contracts/Session/IssuedSession.cs | 36 +- .../Session/ResolvedRefreshSession.cs | 55 ++- .../Contracts/Session/SessionContext.cs | 40 +- .../Session/SessionRefreshRequest.cs | 13 +- .../Contracts/Session/SessionRefreshResult.cs | 57 ++- .../Contracts/Session/SessionResult.cs | 40 -- .../Session/SessionRotationContext.cs | 22 +- .../Session/SessionSecurityContext.cs | 18 +- .../Contracts/Session/SessionStoreContext.cs | 64 +-- .../Contracts/Session/SessionTouchMode.cs | 23 +- .../Session/SessionValidationContext.cs | 16 +- .../Session/SessionValidationResult.cs | 87 ++-- .../Contracts/Token/AccessToken.cs | 47 ++- .../Contracts/Token/AuthTokens.cs | 23 +- .../Contracts/Token/OpaqueTokenRecord.cs | 18 - .../Contracts/Token/PrimaryToken.cs | 28 +- .../Contracts/Token/PrimaryTokenKind.cs | 11 +- .../Contracts/Token/RefreshToken.cs | 33 +- .../Token/RefreshTokenFailureReason.cs | 10 - .../Token/RefreshTokenRotationExecution.cs | 20 +- .../Token/RefreshTokenValidationResult.cs | 96 ++--- .../Contracts/Token/TokenFormat.cs | 13 +- .../Contracts/Token/TokenInvalidReason.cs | 27 +- .../Contracts/Token/TokenIssuanceContext.cs | 20 +- .../Contracts/Token/TokenIssueContext.cs | 14 +- .../Contracts/Token/TokenRefreshContext.cs | 13 +- .../Contracts/Token/TokenType.cs | 14 +- .../Contracts/Token/TokenValidationResult.cs | 118 +++--- .../Contracts/Unit.cs | 9 +- .../Contracts/User/AuthUserSnapshot.cs | 47 ++- .../User/UserAuthenticationResult.cs | 33 +- .../Contracts/User/UserContext.cs | 15 +- .../User/ValidateCredentialsRequest.cs | 24 -- .../Domain/AuthFlowType.cs | 45 +-- .../Domain/Device/DeviceContext.cs | 34 +- .../Domain/Hub/HubCredentials.cs | 13 +- .../Domain/Hub/HubFlowArtifact.cs | 9 +- .../Domain/Hub/HubFlowState.cs | 24 +- .../Domain/Hub/HubSessionId.cs | 19 +- .../Domain/Principals/AuthFailureReason.cs | 23 +- .../Principals/ClaimsSnapshotBuilder.cs | 51 ++- .../Domain/Principals/CredentialKind.cs | 13 +- .../Principals/PrimaryCredentialKind.cs | 11 +- .../Domain/Principals/ReauthBehavior.cs | 13 +- .../Domain/Principals/UAuthClaim.cs | 4 - .../Domain/Session/AuthSessionId.cs | 47 ++- .../Domain/Session/ClaimsSnapshot.cs | 231 ++++++----- .../Domain/Session/ISession.cs | 86 ---- .../Domain/Session/ISessionChain.cs | 66 --- .../Domain/Session/ISessionRoot.cs | 60 --- .../Domain/Session/RefreshOutcome.cs | 17 +- .../Domain/Session/SessionChainId.cs | 45 +-- .../Domain/Session/SessionMetadata.cs | 67 ++- .../Domain/Session/SessionRefreshStatus.cs | 15 +- .../Domain/Session/SessionRootId.cs | 35 +- .../Domain/Session/SessionState.cs | 29 +- .../Domain/Session/UAuthSession.cs | 382 +++++++++--------- .../Domain/Session/UAuthSessionChain.cs | 269 ++++++------ .../Domain/Session/UAuthSessionRoot.cs | 197 ++++----- .../Domain/Token/StoredRefreshToken.cs | 44 +- .../Domain/Token/UAuthJwtTokenDescriptor.cs | 31 +- .../Domain/User/IAuthSubject.cs | 31 +- .../Domain/{ => User}/ICurrentUser.cs | 0 .../Domain/User/UserKey.cs | 96 +++-- .../UAuthChallengeRequiredException.cs | 11 +- .../Base/UAuthAuthorizationException.cs | 11 +- .../Errors/Base/UAuthChainException.cs | 18 +- .../Errors/Base/UAuthDeveloperException.cs | 27 +- .../Errors/Base/UAuthDomainException.cs | 23 +- .../Errors/Base/UAuthException.cs | 39 +- .../Errors/Base/UAuthSessionException.cs | 40 +- .../Errors/Developer/UAuthConfigException.cs | 27 +- .../Developer/UAuthInternalException.cs | 31 +- .../Errors/Developer/UAuthStoreException.cs | 27 +- .../Session/UAuthChainLinkMissingException.cs | 13 +- .../UAuthSessionChainNotFoundException.cs | 11 +- .../UAuthSessionChainRevokedException.cs | 13 +- .../UAuthSessionDeviceMismatchException.cs | 29 +- .../Session/UAuthSessionExpiredException.cs | 35 +- .../UAuthSessionInvalidStateException.cs | 21 +- .../Session/UAuthSessionNotActiveException.cs | 33 +- .../Session/UAuthSessionNotFoundException.cs | 12 +- .../Session/UAuthSessionRevokedException.cs | 37 +- .../UAuthSessionRootRevokedException.cs | 17 +- .../UAuthSessionSecurityMismatchException.cs | 3 +- .../Errors/UAuthDeviceLimitException.cs | 37 +- .../UAuthInvalidCredentialsException.cs | 23 +- .../Errors/UAuthInvalidPkceCodeException.cs | 27 +- .../Errors/UAuthRootRevokedException.cs | 27 +- .../Errors/UAuthTokenTamperedException.cs | 39 +- .../Events/IAuthEventContext.cs | 13 +- .../Events/SessionCreatedContext.cs | 73 ++-- .../Events/SessionRefreshedContext.cs | 95 +++-- .../Events/SessionRevokedContext.cs | 90 ++--- .../Events/UAuthEventDispatcher.cs | 87 ++-- .../Events/UAuthEvents.cs | 95 +++-- .../Events/UserLoggedInContext.cs | 69 ++-- .../Events/UserLoggedOutContext.cs | 67 ++- .../Extensions/ClaimsSnapshotExtensions.cs | 77 ++-- .../Extensions/ServiceCollectionExtensions.cs | 92 +++++ ...UltimateAuthServiceCollectionExtensions.cs | 95 ----- .../UltimateAuthSessionStoreExtensions.cs | 102 ----- .../UserIdConverterRegistrationExtensions.cs | 101 +++-- .../Infrastructure/AuthUserRecord.cs | 50 --- .../Infrastructure/Authority/AuthAuthority.cs | 49 +++ .../Authority/DefaultAuthAuthority.cs | 50 --- .../Authority/DeviceMismatchPolicy.cs | 38 +- .../Authority/DevicePresenceInvariant.cs | 20 +- .../Authority/ExpiredSessionInvariant.cs | 29 +- .../InvalidOrRevokedSessionInvariant.cs | 37 +- .../Authority/UAuthModeOperationPolicy.cs | 55 ++- .../Infrastructure/Base64Url.cs | 72 ++-- .../Infrastructure/GuidUserIdFactory.cs | 9 +- .../Infrastructure/IInMemoryUserIdProvider.cs | 11 +- .../Infrastructure/NoOpAccessTokenIdStore.cs | 20 +- .../Infrastructure/RandomIdGenerator.cs | 54 --- .../Infrastructure/SeedRunner.cs | 10 +- .../Infrastructure/StringUserIdFactory.cs | 9 +- ...dator.cs => UAuthRefreshTokenValidator.cs} | 12 +- .../Infrastructure/UAuthUserIdConverter.cs | 177 ++++---- .../UAuthUserIdConverterResolver.cs | 71 ++-- .../Infrastructure/UserIdFactory.cs | 9 +- .../Infrastructure/UserKeyJsonConverter.cs | 23 +- .../MultiTenancy/CompositeTenantResolver.cs | 53 ++- .../MultiTenancy/FixedTenantResolver.cs | 33 +- .../MultiTenancy/HeaderTenantResolver.cs | 55 ++- .../MultiTenancy/HostTenantResolver.cs | 39 +- .../MultiTenancy/ITenantIdResolver.cs | 23 +- .../MultiTenancy/PathTenantResolver.cs | 59 ++- .../MultiTenancy/TenantContext.cs | 13 + .../MultiTenancy/TenantKey.cs | 102 +++++ .../MultiTenancy/TenantKeys.cs | 7 + .../MultiTenancy/TenantResolutionContext.cs | 105 +++-- .../MultiTenancy/TenantResolutionResult.cs | 23 ++ .../MultiTenancy/TenantValidation.cs | 28 -- .../MultiTenancy/UAuthTenantContext.cs | 35 +- .../Options/HeaderTokenFormat.cs | 11 +- .../Options/IClientProfileDetector.cs | 9 +- .../Options/IServerProfileDetector.cs | 9 - .../Options/TokenResponseMode.cs | 15 +- .../Options/UAuthClientProfile.cs | 21 +- .../Options/UAuthLoginOptions.cs | 31 +- .../Options/UAuthMode.cs | 71 ++-- .../Options/UAuthMultiTenantOptions.cs | 121 +++--- .../UAuthMultiTenantOptionsValidator.cs | 94 +---- .../Options/UAuthOptions.cs | 107 +++-- .../Options/UAuthOptionsValidator.cs | 55 ++- .../Options/UAuthPkceOptions.cs | 37 +- .../Options/UAuthPkceOptionsValidator.cs | 25 +- .../Options/UAuthSessionOptions.cs | 207 +++++----- .../Options/UAuthSessionOptionsValidator.cs | 135 +++---- .../Options/UAuthTokenOptions.cs | 129 +++--- .../Options/UAuthTokenOptionsValidator.cs | 65 ++- .../Runtime/IUAuthHubMarker.cs | 15 +- .../Runtime/IUAuthProductInfoProvider.cs | 9 +- .../Runtime/UAuthProductInfo.cs | 21 +- .../Runtime/UAuthProductInfoProvider.cs | 33 +- .../Abstractions/ICredentialResponseWriter.cs | 13 +- .../Abstractions/IDeviceResolver.cs | 15 +- .../Abstractions/IHttpSessionIssuer.cs | 19 - .../IPrimaryCredentialResolver.cs | 9 +- .../Abstractions/IRefreshTokenResolver.cs | 17 +- .../Abstractions/ISigningKeyProvider.cs | 19 +- .../Abstractions/ITokenIssuer.cs | 19 +- .../Abstractions/ResolvedCredential.cs | 19 - .../Auth/Accessor/AuthFlowContextAccessor.cs | 35 ++ .../DefaultAuthFlowContextAccessor.cs | 47 --- .../Auth/Accessor/IAuthFlowContextAccessor.cs | 10 +- .../Auth/ClientProfileReader.cs | 32 ++ .../Auth/Context/AccessContextFactory.cs | 66 +++ .../Auth/Context/AuthContextFactory.cs | 23 ++ .../Auth/Context/AuthExecutionContext.cs | 9 +- .../Auth/Context/AuthFlow.cs | 28 ++ .../Auth/Context/AuthFlowContext.cs | 98 ++--- .../Auth/Context/AuthFlowContextFactory.cs | 152 +++---- .../Auth/Context/AuthFlowEndpointFilter.cs | 30 +- .../Auth/Context/AuthFlowMetadata.cs | 15 +- .../Context/DefaultAccessContextFactory.cs | 53 --- .../Auth/Context/DefaultAuthContextFactory.cs | 24 -- .../Auth/Context/DefaultAuthFlow.cs | 30 -- .../Auth/Context/IAccessContextFactory.cs | 11 +- .../Auth/Context/IAuthFlow.cs | 9 +- .../Auth/Context/IAuthFlowContextFactory.cs | 9 + .../Auth/DefaultClientProfileReader.cs | 33 -- .../DefaultEffectiveServerOptionsProvider.cs | 47 --- .../Auth/DefaultPrimaryTokenResolver.cs | 21 - .../Auth/EffectiveServerOptionsProvider.cs | 46 +++ .../Auth/EffectiveUAuthServerOptions.cs | 19 +- .../Auth/IClientProfileReader.cs | 9 +- .../Auth/IPrimaryTokenResolver.cs | 9 +- .../Auth/PrimaryTokenResolver.cs | 20 + ...AuthResponseOptionsModeTemplateResolver.cs | 233 ++++++----- .../Auth/Response/AuthResponseResolver.cs | 93 +++++ .../ClientProfileAuthResponseAdapter.cs | 73 ++-- .../Response/DefaultAuthResponseResolver.cs | 94 ----- .../DefaultEffectiveAuthModeResolver.cs | 26 -- .../Response/EffectiveAuthModeResolver.cs | 24 ++ .../Auth/Response/EffectiveAuthResponse.cs | 19 +- .../EffectiveLoginRedirectResponse.cs | 23 +- .../EffectiveLogoutRedirectResponse.cs | 17 +- .../Auth/Response/IAuthResponseResolver.cs | 9 +- .../Response/IEffectiveAuthModeResolver.cs | 9 +- .../UAuthAuthenticationHandler.cs | 62 +-- .../UltimateAuthServerBuilderValidation.cs | 6 +- .../Contracts/JwtSigningKey.cs | 10 + .../Contracts/LogoutResponse.cs | 7 - .../Contracts/RefreshTokenStatus.cs | 19 +- .../Contracts/ResolvedCredential.cs | 19 + .../Contracts/SessionRefreshResult.cs | 28 +- .../Cookies/IUAuthCookiePolicyBuilder.cs | 1 - ...CookieManager.cs => UAuthCookieManager.cs} | 2 +- ...Builder.cs => UAuthCookiePolicyBuilder.cs} | 2 +- .../Defaults/UAuthActions.cs | 118 +++--- .../IAuthorizationEndpointHandler.cs | 17 +- .../Abstractions/ILoginEndpointHandler.cs | 9 +- .../Abstractions/ILogoutEndpointHandler.cs | 9 +- .../Abstractions/IPkceEndpointHandler.cs | 31 +- .../Abstractions/IReauthEndpointHandler.cs | 9 +- .../Abstractions/IRefreshEndpointHandler.cs | 9 +- .../Abstractions/ISessionManagementHandler.cs | 15 +- .../Abstractions/ITokenEndpointHandler.cs | 15 +- .../Abstractions/IUAuthEndpointRegistrar.cs | 9 + .../Abstractions/IUserInfoEndpointHandler.cs | 13 +- .../Abstractions/IValidateEndpointHandler.cs | 9 +- .../Bridges/LoginEndpointHandlerBridge.cs | 16 + .../Bridges/LogoutEndpointHandlerBridge.cs | 17 + .../Bridges/PkceEndpointHandlerBridge.cs | 18 + .../Bridges/RefreshEndpointHandlerBridge.cs | 15 + .../Bridges/ValidateEndpointHandlerBridge.cs | 15 + .../Endpoints/DefaultLogoutEndpointHandler.cs | 75 ---- .../DefaultRefreshEndpointHandler.cs | 91 ----- .../DefaultValidateEndpointHandler.cs | 97 ----- ...ointHandler.cs => LoginEndpointHandler.cs} | 7 +- .../Endpoints/LoginEndpointHandlerBridge.cs | 17 - .../Endpoints/LogoutEndpointHandler.cs | 73 ++++ .../Endpoints/LogoutEndpointHandlerBridge.cs | 18 - ...pointHandler.cs => PkceEndpointHandler.cs} | 30 +- .../Endpoints/PkceEndpointHandlerBridge.cs | 19 - .../Endpoints/RefreshEndpointHandler.cs | 90 +++++ .../Endpoints/RefreshEndpointHandlerBridge.cs | 16 - .../Endpoints/UAuthEndpointDefaults.cs | 15 - .../Endpoints/UAuthEndpointRegistrar.cs | 346 ++++++++-------- .../Endpoints/ValidateEndpointHandler.cs | 109 +++++ .../ValidateEndpointHandlerBridge.cs | 17 - .../Extensions/AuthFlowContextExtensions.cs | 58 ++- .../Extensions/AuthFlowTypeExtensions.cs | 9 +- .../Extensions/ClaimsSnapshotExtensions.cs | 14 +- .../Extensions/DeviceExtensions.cs | 15 +- .../EndpointRouteBuilderExtensions.cs | 28 +- .../Extensions/HttpContextJsonExtensions.cs | 29 +- .../HttpContextSessionExtensions.cs | 19 +- .../Extensions/HttpContextTenantExtensions.cs | 23 +- .../Extensions/HttpContextUserExtensions.cs | 17 +- .../Extensions/ServiceCollectionExtensions.cs | 307 ++++++++++++++ .../TenantResolutionContextExtensions.cs | 25 -- .../UAuthApplicationBuilderExtensions.cs | 18 +- .../UAuthServerOptionsExtensions.cs | 11 +- .../UAuthServerServiceCollectionExtensions.cs | 372 ----------------- .../Flows/Login/ILoginAuthority.cs | 16 + .../Flows/Login/ILoginOrchestrator.cs | 15 + .../Flows/Login/LoginAuthority.cs | 33 ++ .../Flows/Login/LoginDecision.cs | 25 ++ .../Flows/Login/LoginDecisionContext.cs | 50 +++ .../Flows/Login/LoginDecisionKind.cs | 8 + .../Flows/Login/LoginOrchestrator.cs | 167 ++++++++ .../Pkce/IPkceAuthorizationValidator.cs | 2 +- .../Pkce/PkceAuthorizationArtifact.cs | 2 +- .../Pkce/PkceAuthorizationValidator.cs | 17 +- .../Pkce/PkceAuthorizeRequest.cs | 2 +- .../Flows/Pkce/PkceChallengeMethod.cs | 6 + .../Pkce/PkceContextSnapshot.cs | 11 +- .../Pkce/PkceValidationFailureReason.cs | 2 +- .../Pkce/PkceValidationResult.cs | 2 +- .../Flows/Refresh/IRefreshResponsePolicy.cs | 11 + .../Flows/Refresh/IRefreshResponseWriter.cs | 9 + .../Flows/Refresh/IRefreshService.cs | 9 + .../Flows/Refresh/ISessionTouchService.cs | 12 + .../Flows/Refresh/RefreshDecision.cs | 29 ++ .../Flows/Refresh/RefreshDecisionResolver.cs | 24 ++ .../Flows/Refresh/RefreshEvaluationResult.cs | 5 + .../Flows/Refresh/RefreshResponsePolicy.cs | 44 ++ .../Flows/Refresh/RefreshResponseWriter.cs | 31 ++ .../Flows/Refresh/RefreshStrategyResolver.cs | 20 + .../Flows/Refresh/RefreshTokenResolver.cs | 40 ++ .../Flows/Refresh/SessionTouchPolicy.cs | 6 + .../Flows/Refresh/SessionTouchService.cs | 45 +++ ...icyProvider.cs => AccessPolicyProvider.cs} | 4 +- .../Infrastructure/AuthRedirectResolver.cs | 86 ++-- .../DefaultTransportCredentialResolver.cs | 171 -------- .../ITransportCredentialResolver.cs | 9 +- .../AspNetCore/TransportCredential.cs | 15 +- .../AspNetCore/TransportCredentialKind.cs | 15 +- .../AspNetCore/TransportCredentialResolver.cs | 160 ++++++++ ...eWriter.cs => CredentialResponseWriter.cs} | 7 +- .../DefaultFlowCredentialResolver.cs | 93 ----- .../DefaultPrimaryCredentialResolver.cs | 39 -- .../DefaultUAuthBodyPolicyBuilder.cs | 13 - .../DefaultUAuthHeaderPolicyBuilder.cs | 23 -- .../Credentials/FlowCredentialResolver.cs | 91 +++++ .../Credentials/IFlowCredentialResolver.cs | 21 +- .../Credentials/PrimaryCredentialResolver.cs | 37 ++ .../Credentials/UAuthBodyPolicyBuilder.cs | 12 + .../Credentials/UAuthHeaderPolicyBuilder.cs | 21 + .../DefaultJwtTokenGenerator.cs | 71 ---- .../DefaultOpaqueTokenGenerator.cs | 11 - .../DevelopmentJwtSigningKeyProvider.cs | 40 +- .../Device/DefaultDeviceContextFactory.cs | 17 - .../Device/DefaultDeviceResolver.cs | 58 --- .../Device/DeviceContextFactory.cs | 15 + .../Infrastructure/Device/DeviceResolver.cs | 57 +++ .../Device/IDeviceContextFactory.cs | 9 +- .../Infrastructure/HmacSha256TokenHasher.cs | 48 ++- .../Hub/DefaultHubCredentialResolver.cs | 40 -- .../Hub/DefaultHubFlowReader.cs | 41 -- .../Hub/HubCredentialResolver.cs | 39 ++ .../Infrastructure/Hub/HubFlowReader.cs | 39 ++ .../Infrastructure/HubCapabilities.cs | 9 +- .../Issuers/UAuthSessionIssuer.cs | 203 ++++++++++ .../Issuers/UAuthTokenIssuer.cs | 145 +++++++ .../Infrastructure/JwtTokenGenerator.cs | 66 +++ .../Infrastructure/OpaqueTokenGenerator.cs | 8 + .../Orchestrator/CreateLoginSessionCommand.cs | 11 +- .../Orchestrator/DefaultAccessAuthority.cs | 60 --- .../Orchestrator/IAccessCommand.cs | 20 +- .../Orchestrator/IAccessOrchestrator.cs | 11 +- .../Orchestrator/ISessionCommand.cs | 9 +- .../Orchestrator/ISessionOrchestrator.cs | 10 +- .../Orchestrator/RevokeAllChainsCommand.cs | 29 +- .../Orchestrator/RevokeAllSessionsCommand.cs | 29 +- .../Orchestrator/RevokeChainCommand.cs | 33 +- .../Orchestrator/RevokeRootCommand.cs | 25 +- .../Orchestrator/RevokeSessionCommand.cs | 13 +- .../Orchestrator/RotateSessionCommand.cs | 11 +- .../Orchestrator/UAuthAccessAuthority.cs | 59 +++ .../Orchestrator/UAuthAccessOrchestrator.cs | 63 ++- .../Orchestrator/UAuthSessionOrchestrator.cs | 55 ++- .../Pkce/PkceChallengeMethod.cs | 6 - .../Refresh/DefaultRefreshResponsePolicy.cs | 45 --- .../Refresh/DefaultRefreshResponseWriter.cs | 32 -- .../Refresh/DefaultRefreshTokenResolver.cs | 41 -- .../Refresh/DefaultSessionTouchService.cs | 42 -- .../Refresh/IRefreshResponsePolicy.cs | 12 - .../Refresh/IRefreshResponseWriter.cs | 10 - .../Infrastructure/Refresh/IRefreshService.cs | 10 - .../Refresh/ISessionTouchService.cs | 13 - .../Infrastructure/Refresh/RefreshDecision.cs | 30 -- .../Refresh/RefreshDecisionResolver.cs | 24 -- .../Refresh/RefreshEvaluationResult.cs | 6 - .../Refresh/RefreshStrategyResolver.cs | 21 - .../Refresh/SessionTouchPolicy.cs | 7 - .../Session/DefaultSessionContextAccessor.cs | 30 -- .../Session/ISessionContextAccessor.cs | 15 +- .../Session/SessionContextAccessor.cs | 29 ++ .../Session/SessionContextItemKeys.cs | 9 +- .../Session/SessionValidationMapper.cs | 38 +- .../SessionId/BearerSessionIdResolver.cs | 36 +- .../SessionId/CompositeSessionIdResolver.cs | 33 +- .../SessionId/CookieSessionIdResolver.cs | 39 +- .../SessionId/HeaderSessionIdResolver.cs | 41 +- .../SessionId/IInnerSessionIdResolver.cs | 11 +- .../SessionId/ISessionIdResolver.cs | 9 +- .../SessionId/QuerySessionIdResolver.cs | 41 +- .../Infrastructure/SystemClock.cs | 9 +- .../User/HttpContextCurrentUser.cs | 24 +- .../Infrastructure/User/IUserAccessor.cs | 17 +- .../Infrastructure/User/UAuthUserAccessor.cs | 55 +-- .../Infrastructure/User/UAuthUserId.cs | 15 +- .../Infrastructure/User/UserAccessorBridge.cs | 27 +- .../Issuers/UAuthSessionIssuer.cs | 179 -------- .../Issuers/UAuthTokenIssuer.cs | 145 ------- .../Login/DefaultLoginAuthority.cs | 34 -- .../Login/DefaultLoginOrchestrator.cs | 169 -------- .../Login/ILoginAuthority.cs | 17 - .../Login/ILoginOrchestrator.cs | 16 - .../Login/LoginDecision.cs | 26 -- .../Login/LoginDecisionContext.cs | 50 --- .../Login/LoginDecisionKind.cs | 9 - .../SessionResolutionMiddleware.cs | 39 +- .../Middlewares/TenantMiddleware.cs | 59 +-- .../Middlewares/UserMiddleware.cs | 29 +- .../MultiTenancy/ITenantResolver.cs | 10 +- .../TenantResolutionContextFactory.cs | 38 +- .../MultiTenancy/UAuthTenantContextFactory.cs | 28 +- .../MultiTenancy/UAuthTenantResolver.cs | 84 ++-- .../Options/AuthResponseOptions.cs | 33 +- .../Options/CredentialResponseOptions.cs | 79 ++-- .../Options/Defaults/ConfigureDefaults.cs | 223 +++++----- .../IEffectiveServerOptionsProvider.cs | 11 +- .../Options/LoginRedirectOptions.cs | 38 +- .../Options/LogoutRedirectOptions.cs | 43 +- .../Options/PrimaryCredentialPolicy.cs | 34 +- .../Options/UAuthCookieLifetimeOptions.cs | 37 +- .../Options/UAuthCookieSetOptions.cs | 61 ++- .../Options/UAuthDiagnosticsOptions.cs | 25 +- .../Options/UAuthHubServerOptions.cs | 37 +- .../Options/UAuthServerOptions.cs | 321 ++++++++------- .../Options/UAuthServerOptionsValidator.cs | 81 ++-- .../Options/UAuthServerProfileDetector.cs | 28 -- .../Options/UAuthSessionResolutionOptions.cs | 61 ++- .../Options/UserIdentifierOptions.cs | 46 +-- .../ProductInfo/UAuthServerProductInfo.cs | 21 +- .../Services/DefaultRefreshFlowService.cs | 241 ----------- .../Services/DefaultSessionService.cs | 35 -- .../Services/IRefreshFlowService.cs | 9 +- .../Services/IRefreshTokenRotationService.cs | 9 +- .../Services/ISessionQueryService.cs | 37 +- .../Services/ISessionValidator.cs | 13 + .../Services/IUAuthFlowService.cs | 31 +- .../Services/RefreshFlowService.cs | 207 ++++++++++ .../Services/RefreshTokenRotationService.cs | 34 +- .../Services/UAuthFlowService.cs | 152 +++---- .../Services/UAuthJwtValidator.cs | 122 +++--- .../Services/UAuthSessionManager.cs | 110 ++--- .../Services/UAuthSessionQueryService.cs | 104 ++--- .../Services/UAuthSessionValidator.cs | 58 +++ .../Stores/AspNetIdentityUserStore.cs | 56 ++- .../Stores/UAuthSessionStoreFactory.cs | 33 -- .../Requests/AssignRoleRequest.cs | 9 +- .../Requests/AuthorizationCheckRequest.cs | 13 +- .../Responses/UserRolesResponse.cs | 12 +- .../AuthorizationInMemoryExtensions.cs | 19 - .../Extensions/ServiceCollectionExtensions.cs | 18 + .../IAuthorizationSeeder.cs | 9 +- .../InMemoryAuthorizationSeedContributor.cs | 6 +- .../Stores/InMemoryUserRoleStore.cs | 15 +- .../Commands/AssignUserRoleCommand.cs | 27 +- .../Commands/GetUserRolesCommand.cs | 27 +- .../Commands/RemoveUserRoleCommand.cs | 27 +- .../Endpoints/AuthorizationEndpointHandler.cs | 137 +++++++ .../DefaultAuthorizationEndpointHandler.cs | 132 ------ .../AuthorizationReferenceExtensions.cs | 21 - .../Extensions/ServiceCollectionExtensions.cs | 19 + .../DefaultRolePermissionResolver.cs | 35 -- .../DefaultUserPermissionStore.cs | 24 -- .../Infrastructure/RolePermissionResolver.cs | 34 ++ .../Infrastructure/UserPermissionStore.cs | 23 ++ .../Services/AuthorizationService.cs | 36 ++ .../Services/DefaultAuthorizationService.cs | 37 -- .../Services/DefaultUserRoleService.cs | 62 --- .../Services/IAuthorizationService.cs | 10 +- .../Services/UserRoleService.cs | 59 +++ .../Abstractions/IRolePermissionResolver.cs | 10 +- .../Abstractions/IUserPermissionStore.cs | 3 +- .../Abstractions/IUserRoleService.cs | 13 +- .../Abstractions/IUserRoleStore.cs | 14 +- .../AuthorizationClaimsProvider.cs | 34 ++ .../DefaultAuthorizationClaimsProvider.cs | 35 -- .../Dtos/CredentialDto.cs | 25 +- .../Dtos/CredentialMetadata.cs | 10 +- .../Extensions/CredentialTypeParser.cs | 47 ++- .../Request/AddCredentialRequest.cs | 13 +- .../Request/BeginCredentialResetRequest.cs | 9 +- .../Request/CompleteCredentialResetRequest.cs | 11 +- .../Request/ResetPasswordRequest.cs | 19 +- .../Request/RevokeAllCredentialsRequest.cs | 9 +- .../Request/RevokeCredentialRequest.cs | 17 +- .../Responses/AddCredentialResult.cs | 34 +- .../Responses/ChangeCredentialResult.cs | 23 +- .../Responses/CredentialActionResult.cs | 30 +- .../Responses/CredentialChangeResult.cs | 15 +- .../Responses/CredentialProvisionResult.cs | 2 - .../Responses/CredentialValidationResult.cs | 61 +-- .../CredentialValidationResultDto.cs | 11 - .../Responses/GetCredentialsResult.cs | 7 +- ...uth.Credentials.EntityFrameworkCore.csproj | 1 + .../Configuration/ConventionResolver.cs | 29 +- .../CredentialUserMappingBuilder.cs | 113 +++--- .../EfCoreAuthUser.cs | 17 +- .../Infrastructure/EfCoreUserStore.cs | 83 ---- .../ServiceCollectionExtensions.cs | 10 +- ...m.UltimateAuth.Credentials.InMemory.csproj | 1 + .../InMemoryCredentialSeedContributor.cs | 62 +-- .../InMemoryCredentialStore.cs | 57 ++- .../InMemoryPasswordCredentialState.cs | 19 +- ...ions.cs => ServiceCollectionExtensions.cs} | 2 +- .../Commands/ActivateCredentialCommand.cs | 4 +- .../Commands/AddCredentialCommand.cs | 22 +- .../Commands/BeginCredentialResetCommand.cs | 4 +- .../Commands/ChangeCredentialCommand.cs | 3 +- .../Commands/GetAllCredentialsCommand.cs | 19 +- .../Commands/SetInitialCredentialCommand.cs | 19 +- ...andler.cs => CredentialEndpointHandler.cs} | 7 +- .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../PasswordUserLifecycleIntegration.cs | 11 +- .../IUserCredentialsInternalService.cs | 10 +- ...lsService.cs => UserCredentialsService.cs} | 55 +-- .../Abstractions/ICredentialDescriptor.cs | 13 +- .../Abstractions/ICredentialSecretStore.cs | 12 +- .../Abstractions/ICredentialStore.cs | 28 +- .../Abstractions/IPublicKeyCredential.cs | 9 +- .../Abstractions/ISecurableCredential.cs | 9 +- ...ialValidator.cs => CredentialValidator.cs} | 4 +- .../CodeBeam.UltimateAuth.Policies.csproj | 5 - .../Defaults/CompiledAccessPolicySet.cs | 37 +- .../Fluent/ConditionalPolicyBuilder.cs | 39 +- .../Fluent/ConditionalScopeBuilder.cs | 62 ++- .../Fluent/IConditionalPolicyBuilder.cs | 11 +- .../Fluent/IPolicyBuilder.cs | 11 +- .../Fluent/IPolicyScopeBuilder.cs | 17 +- .../Fluent/PolicyBuilder.cs | 25 +- .../Fluent/PolicyScopeBuilder.cs | 68 ++-- .../Policies/ConditionalAccessPolicy.cs | 29 +- .../Policies/RequireActiveUserPolicy.cs | 77 ++-- .../Policies/RequireSystemPolicy.cs | 17 +- .../Argon2Options.cs | 19 +- .../Argon2PasswordHasher.cs | 81 ++-- ...deBeam.UltimateAuth.Security.Argon2.csproj | 1 + .../ServiceCollectionExtensions.cs | 19 +- .../AuthSessionIdEfConverter.cs | 38 -- .../Data/UAuthSessionDbContext.cs | 122 ++++++ .../EfCoreSessionStore.cs | 376 ----------------- .../EfCoreSessionStoreKernel.cs | 246 ----------- .../EfCoreSessionStoreKernelFactory.cs | 20 - .../SessionChainProjection.cs | 32 +- .../EntityProjections/SessionProjection.cs | 39 +- .../SessionRootProjection.cs | 26 +- .../ServiceCollectionExtensions.cs | 4 +- .../AuthSessionIdEfConverter.cs | 34 ++ .../Infrastructure/JsonValueConverter.cs | 14 + .../NullableAuthSessionIdConverter.cs | 18 + .../JsonValueConverter.cs | 15 - .../Mappers/SessionChainProjectionMapper.cs | 63 ++- .../Mappers/SessionProjectionMapper.cs | 77 ++-- .../Mappers/SessionRootProjectionMapper.cs | 54 ++- .../NullableAuthSessionIdConverter.cs | 19 - .../Stores/EfCoreSessionStoreKernel.cs | 248 ++++++++++++ .../Stores/EfCoreSessionStoreKernelFactory.cs | 26 ++ .../UAuthSessionDbContext.cs | 110 ----- .../InMemorySessionStore.cs | 154 ------- .../InMemorySessionStoreFactory.cs | 24 -- .../InMemorySessionStoreKernel.cs | 50 +-- .../InMemorySessionStoreKernelFactory.cs | 15 + .../ServiceCollectionExtensions.cs | 15 +- .../EfCoreTokenStore.cs | 35 +- .../Projections/RefreshTokenProjection.cs | 3 +- .../Projections/RevokedIdTokenProjection.cs | 6 +- .../UAuthTokenDbContext.cs | 14 +- .../InMemoryRefreshTokenStore.cs | 27 +- .../Dtos/MfaMethod.cs | 15 +- .../Dtos/UserAccessDecision.cs | 6 - .../Dtos/UserIdentifierDto.cs | 19 +- .../Dtos/UserIdentifierType.cs | 13 +- .../Dtos/UserMfaStatusDto.cs | 17 +- .../Dtos/UserProfileInput.cs | 10 - .../Dtos/UserStatus.cs | 25 +- .../Dtos/UserViewDto.cs | 37 +- .../Requests/AddUserIdentifierRequest.cs | 13 +- .../Requests/BeginMfaSetupRequest.cs | 9 +- .../Requests/ChangeUserIdentifierRequest.cs | 13 +- .../Requests/ChangeUserStatusAdminRequest.cs | 11 +- .../Requests/ChangeUserStatusSelfRequest.cs | 9 +- .../Requests/CompleteMfaSetupRequest.cs | 11 +- .../Requests/DeleteUserIdentifierRequest.cs | 13 +- .../Requests/DeleteUserRequest.cs | 10 +- .../Requests/DisableMfaRequest.cs | 9 +- .../Requests/RegisterUserRequest.cs | 47 +-- .../SerPrimaryUserIdentifierRequest.cs | 8 - .../SetPrimaryUserIdentifierRequest.cs | 7 + .../UnsetPrimaryUserIdentifierRequest.cs | 11 +- .../Requests/UpdateUserIdentifierRequest.cs | 13 +- .../Requests/VerifyUserIdentifierRequest.cs | 11 +- .../Responses/BeginMfaSetupResult.cs | 13 +- .../Responses/GetUserIdentifiersResult.cs | 9 +- .../Responses/IdentifierChangeResult.cs | 15 +- .../Responses/IdentifierDeleteResult.cs | 15 +- .../Responses/IdentifierVerificationResult.cs | 15 +- .../Extensions/ServiceCollectionExtensions.cs | 25 ++ .../UltimateAuthUsersInMemoryExtensions.cs | 26 -- .../Infrastructure/InMemoryUserIdProvider.cs | 16 +- .../InMemoryUserSecurityStateProvider.cs | 15 +- .../InMemoryUserSeedContributor.cs | 113 +++--- .../Stores/InMemoryUserIdentifierStore.cs | 254 ++++++------ .../Stores/InMemoryUserLifecycleStore.cs | 31 +- .../Stores/InMemoryUserProfileStore.cs | 27 +- .../Commands/AddUserIdentifierCommand.cs | 19 +- .../Commands/ChangeUserStatusCommand.cs | 23 +- .../Commands/CreateUserCommand.cs | 23 +- .../Commands/DeleteUserCommand.cs | 23 +- .../Commands/DeleteUserIdentifierCommand.cs | 23 +- .../Commands/GetMeCommand.cs | 4 +- .../Commands/GetUserIdentifierCommand.cs | 24 +- .../Commands/GetUserIdentifiersCommand.cs | 23 +- .../Commands/GetUserProfileCommand.cs | 4 +- .../SetPrimaryUserIdentifierCommand.cs | 19 +- .../UnsetPrimaryUserIdentifierCommand.cs | 19 +- .../Commands/UpdateUserIdentifierCommand.cs | 19 +- .../Commands/UpdateUserProfileAdminCommand.cs | 4 +- .../Commands/UserIdentifierExistsCommand.cs | 24 +- .../Commands/VerifyUserIdentifierCommand.cs | 23 +- .../Contracts/UserLifecycleQuery.cs | 15 +- .../Contracts/UserProfileQuery.cs | 13 +- .../Domain/UserIdentifier.cs | 3 +- .../Domain/UserLifecycle.cs | 3 +- .../Domain/UserProfile.cs | 3 +- ...pointHandler.cs => UserEndpointHandler.cs} | 4 +- .../Extensions/ServiceCollectonExtensions.cs | 2 +- .../Mapping/UserIdentifierMapper.cs | 27 +- .../Mapping/UserProfileMapper.cs | 27 +- .../Services/IUserApplicationService.cs | 37 +- .../Services/UserApplicationService.cs | 56 +-- .../Stores/IUserIdentifierStore.cs | 21 +- .../Stores/IUserLifecycleStore.cs | 22 +- .../Stores/IUserProfileStore.cs | 13 +- .../Stores/UserRuntimeStore.cs | 42 +- .../Abstractions/IUser.cs | 11 +- .../Abstractions/IUserLifecycleIntegration.cs | 5 +- .../IUserSecurityStateProvider.cs | 11 +- .../BlazorServerSessionCoordinatorTests.cs | 156 ++++--- .../Client/ClientDiagnosticsTests.cs | 193 +++++---- .../Client/RefreshOutcomeParserTests.cs | 46 +-- .../Core/RefreshTokenValidatorTests.cs | 178 ++++---- .../Core/UAuthSessionChainTests.cs | 13 +- .../Core/UAuthSessionTests.cs | 6 +- .../Core/UserIdConverterTests.cs | 141 ++++--- .../CredentialUserMappingBuilderTests.cs | 149 ++++--- .../Fake/FakeFlowClient.cs | 119 +++--- .../Fake/FakeNavigationManager.cs | 23 +- .../Policies/ActionTextTests.cs | 43 +- .../Server/EffectiveAuthModeResolverTests.cs | 56 ++- .../EffectiveServerOptionsProviderTests.cs | 215 +++++----- .../TestHelpers.cs | 11 +- .../TestIds.cs | 18 +- 757 files changed, 14137 insertions(+), 16416 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs rename src/CodeBeam.UltimateAuth.Client/Device/{DefaultDeviceIdGenerator.cs => UAuthDeviceIdGenerator.cs} (85%) rename src/CodeBeam.UltimateAuth.Client/Device/{DefaultDeviceIdProvider.cs => UAuthDeviceIdProvider.cs} (84%) create mode 100644 src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultFlowClient.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Storage/.gitkeep delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Principals/UAuthClaim.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs rename src/CodeBeam.UltimateAuth.Core/Domain/{ => User}/ICurrentUser.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/AuthAuthority.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{DefaultRefreshTokenValidator.cs => UAuthRefreshTokenValidator.cs} (81%) create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKeys.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionResult.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantValidation.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Options/IServerProfileDetector.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Accessor/AuthFlowContextAccessor.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlow.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAccessContextFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthContextFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/DefaultClientProfileReader.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/DefaultEffectiveServerOptionsProvider.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/DefaultPrimaryTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/PrimaryTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultAuthResponseResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultEffectiveAuthModeResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/JwtSigningKey.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs rename src/CodeBeam.UltimateAuth.Server/Cookies/{DefaultUAuthCookieManager.cs => UAuthCookieManager.cs} (88%) rename src/CodeBeam.UltimateAuth.Server/Cookies/{DefaultUAuthCookiePolicyBuilder.cs => UAuthCookiePolicyBuilder.cs} (97%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUAuthEndpointRegistrar.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/RefreshEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/ValidateEndpointHandlerBridge.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs rename src/CodeBeam.UltimateAuth.Server/Endpoints/{DefaultLoginEndpointHandler.cs => LoginEndpointHandler.cs} (94%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs rename src/CodeBeam.UltimateAuth.Server/Endpoints/{DefaultPkceEndpointHandler.cs => PkceEndpointHandler.cs} (89%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaults.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/TenantResolutionContextExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecision.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionKind.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs rename src/CodeBeam.UltimateAuth.Server/{Infrastructure => Flows}/Pkce/IPkceAuthorizationValidator.cs (77%) rename src/CodeBeam.UltimateAuth.Server/{Infrastructure => Flows}/Pkce/PkceAuthorizationArtifact.cs (96%) rename src/CodeBeam.UltimateAuth.Server/{Infrastructure => Flows}/Pkce/PkceAuthorizationValidator.cs (82%) rename src/CodeBeam.UltimateAuth.Server/{Infrastructure => Flows}/Pkce/PkceAuthorizeRequest.cs (78%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceChallengeMethod.cs rename src/CodeBeam.UltimateAuth.Server/{Infrastructure => Flows}/Pkce/PkceContextSnapshot.cs (82%) rename src/CodeBeam.UltimateAuth.Server/{Infrastructure => Flows}/Pkce/PkceValidationFailureReason.cs (75%) rename src/CodeBeam.UltimateAuth.Server/{Infrastructure => Flows}/Pkce/PkceValidationResult.cs (89%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponseWriter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/ISessionTouchService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecision.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshEvaluationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshStrategyResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{DefaultAccessPolicyProvider.cs => AccessPolicyProvider.cs} (76%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/{DefaultCredentialResponseWriter.cs => CredentialResponseWriter.cs} (93%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultFlowCredentialResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultPrimaryCredentialResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthBodyPolicyBuilder.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthHeaderPolicyBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthBodyPolicyBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthHeaderPolicyBuilder.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultJwtTokenGenerator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultOpaqueTokenGenerator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/JwtTokenGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponseWriter.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponseWriter.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshEvaluationResult.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/DefaultSessionContextAccessor.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/ISessionValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultUserRoleService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs rename src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/{UltimateAuthDefaultsInMemoryExtensions.cs => ServiceCollectionExtensions.cs} (92%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/{DefaultCredentialEndpointHandler.cs => CredentialEndpointHandler.cs} (97%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/{DefaultUserCredentialsService.cs => UserCredentialsService.cs} (83%) rename src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/{DefaultCredentialValidator.cs => CredentialValidator.cs} (88%) delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/{ => Extensions}/ServiceCollectionExtensions.cs (77%) create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs rename src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/{DefaultUserEndpointHandler.cs => UserEndpointHandler.cs} (98%) diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs index 00bebb1b..5d81798e 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -27,7 +27,8 @@ protected override async Task OnParametersSetAsync() return; } - _state = await HubFlowReader.GetStateAsync(new HubSessionId(HubKey)); + if (!HubSessionId.TryParse(HubKey, out var hubSessionId)) + _state = await HubFlowReader.GetStateAsync(hubSessionId); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -74,7 +75,10 @@ private async Task ProgrammaticPkceLogin() if (hub is null) return; - var credentials = await HubCredentialResolver.ResolveAsync(new HubSessionId(HubKey)); + if (!HubSessionId.TryParse(HubKey, out var hubSessionId)) + return; + + var credentials = await HubCredentialResolver.ResolveAsync(hubSessionId); var request = new PkceLoginRequest { diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs index 30e32614..fa7a1ae5 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Stores; @@ -41,7 +42,7 @@ public async Task BeginLogin( hubSessionId: hubSessionId, flowType: HubFlowType.Login, clientProfile: client_profile, - tenantId: null, + tenant: TenantKeys.System, returnUrl: return_url, payload: payload, expiresAt: _clock.UtcNow.Add(_options.Hub.FlowLifetime)); 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 index ebf7a93f..259133b0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -60,7 +60,7 @@ UAuthState @(StateManager.State.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(StateManager.State.UserKey) - Authorized context is shown. @context.User.Identity.IsAuthenticated + Authorized context is shown. @context?.User?.Identity?.IsAuthenticated Not Authorized context is shown. diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 1d022200..6791f6d6 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -6,6 +6,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; @@ -112,13 +113,9 @@ app.MapOpenApi(); app.MapScalarApiReference(); using var scope = app.Services.CreateScope(); - //scope.ServiceProvider.GetRequiredService(); - //scope.ServiceProvider.GetRequiredService(); - //scope.ServiceProvider.GetRequiredService(); - //scope.ServiceProvider.GetRequiredService>(); var seedRunner = scope.ServiceProvider.GetRequiredService(); - await seedRunner.RunAsync(tenantId: null); + await seedRunner.RunAsync(null); } app.UseHttpsRedirection(); 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 index 44c873a9..e9530d89 100644 --- 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 @@ -11,7 +11,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages public partial class Home { [CascadingParameter] - public UAuthState Auth { get; set; } + public UAuthState Auth { get; set; } = null!; private string? _username; private string? _password; diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs index f2f18eba..77e8b19a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs @@ -1,12 +1,11 @@ using CodeBeam.UltimateAuth.Client.Contracts; -namespace CodeBeam.UltimateAuth.Client.Utilities +namespace CodeBeam.UltimateAuth.Client.Utilities; + +public interface IBrowserStorage { - public interface IBrowserStorage - { - 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); - } + 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/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs index d71d9f57..ce1781aa 100644 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs @@ -1,18 +1,17 @@ -namespace CodeBeam.UltimateAuth.Client.Abstractions +namespace CodeBeam.UltimateAuth.Client.Abstractions; + +public interface ISessionCoordinator : IAsyncDisposable { - public interface ISessionCoordinator : IAsyncDisposable - { - /// - /// Starts session coordination. - /// Should be idempotent. - /// - Task StartAsync(CancellationToken cancellationToken = default); + /// + /// Starts session coordination. + /// Should be idempotent. + /// + Task StartAsync(CancellationToken cancellationToken = default); - /// - /// Stops coordination (optional). - /// - Task StopAsync(); + /// + /// Stops coordination (optional). + /// + Task StopAsync(); - event Action? ReauthRequired; - } + event Action? ReauthRequired; } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs deleted file mode 100644 index 1eb5b372..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/DefaultUAuthStateManager.cs +++ /dev/null @@ -1,55 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Runtime; -using CodeBeam.UltimateAuth.Core.Abstractions; - -namespace CodeBeam.UltimateAuth.Client.Authentication -{ - internal sealed class DefaultUAuthStateManager : IUAuthStateManager - { - private readonly IUAuthClient _client; - private readonly IClock _clock; - private readonly IUAuthClientBootstrapper _bootstrapper; - - public UAuthState State { get; } = UAuthState.Anonymous(); - - public DefaultUAuthStateManager(IUAuthClient client, IClock clock, IUAuthClientBootstrapper bootstrapper) - { - _client = client; - _clock = clock; - _bootstrapper = bootstrapper; - } - - public async Task EnsureAsync(CancellationToken ct = default) - { - if (State.IsAuthenticated && !State.IsStale) - return; - - await _bootstrapper.EnsureStartedAsync(); - var result = await _client.Flows.ValidateAsync(); - - if (!result.IsValid) - { - State.Clear(); - return; - } - - State.ApplySnapshot(result.Snapshot, _clock.UtcNow); - } - - public Task OnLoginAsync() - { - State.MarkStale(); - return Task.CompletedTask; - } - - public Task OnLogoutAsync() - { - State.Clear(); - return Task.CompletedTask; - } - - public void MarkStale() - { - State.MarkStale(); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs index e97c8c87..a48b5bdf 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs @@ -1,36 +1,35 @@ -namespace CodeBeam.UltimateAuth.Client.Authentication +namespace CodeBeam.UltimateAuth.Client.Authentication; + +/// +/// 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 { /// - /// Orchestrates the lifecycle of UAuthState. - /// This is the single authority responsible for keeping - /// client-side authentication state in sync with the server. + /// Current in-memory authentication state. /// - public interface IUAuthStateManager - { - /// - /// Current in-memory authentication state. - /// - UAuthState State { get; } + UAuthState State { get; } - /// - /// Ensures the authentication state is valid. - /// May call server validate/refresh if needed. - /// - Task EnsureAsync(CancellationToken ct = default); + /// + /// Ensures the authentication state is valid. + /// May call server validate/refresh if needed. + /// + Task EnsureAsync(CancellationToken ct = default); - /// - /// Called after a successful login. - /// - Task OnLoginAsync(); + /// + /// Called after a successful login. + /// + Task OnLoginAsync(); - /// - /// Called after logout. - /// - Task OnLogoutAsync(); + /// + /// Called after logout. + /// + Task OnLogoutAsync(); - /// - /// Forces state to be cleared and re-validation required. - /// - void MarkStale(); - } + /// + /// Forces state to be cleared and re-validation required. + /// + void MarkStale(); } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs index 89d48fad..57c64931 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs @@ -1,24 +1,21 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using Microsoft.AspNetCore.Components.Authorization; -using System.Security.Principal; +using Microsoft.AspNetCore.Components.Authorization; -namespace CodeBeam.UltimateAuth.Client.Authentication -{ - internal sealed class UAuthAuthenticationStateProvider : AuthenticationStateProvider - { - private readonly IUAuthStateManager _stateManager; +namespace CodeBeam.UltimateAuth.Client.Authentication; - public UAuthAuthenticationStateProvider(IUAuthStateManager stateManager) - { - _stateManager = stateManager; - _stateManager.State.Changed += _ => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - } +internal sealed class UAuthAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly IUAuthStateManager _stateManager; - public override Task GetAuthenticationStateAsync() - { - var principal = _stateManager.State.ToClaimsPrincipal(); - return Task.FromResult(new AuthenticationState(principal)); - } + 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/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs index 5c45d210..c8733f43 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs @@ -1,26 +1,25 @@ using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Client.Authentication +namespace CodeBeam.UltimateAuth.Client.Authentication; + +internal sealed class UAuthCascadingStateProvider : CascadingValueSource, IDisposable { - internal sealed class UAuthCascadingStateProvider : CascadingValueSource, IDisposable - { - private readonly IUAuthStateManager _stateManager; + private readonly IUAuthStateManager _stateManager; - public UAuthCascadingStateProvider(IUAuthStateManager stateManager) - : base(() => stateManager.State, isFixed: false) - { - _stateManager = stateManager; - _stateManager.State.Changed += OnStateChanged; - } + public UAuthCascadingStateProvider(IUAuthStateManager stateManager) + : base(() => stateManager.State, isFixed: false) + { + _stateManager = stateManager; + _stateManager.State.Changed += OnStateChanged; + } - private void OnStateChanged(UAuthStateChangeReason _) - { - NotifyChangedAsync(); - } + private void OnStateChanged(UAuthStateChangeReason _) + { + NotifyChangedAsync(); + } - public void Dispose() - { - _stateManager.State.Changed -= OnStateChanged; - } + public void Dispose() + { + _stateManager.State.Changed -= OnStateChanged; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs index 10ef9f57..405abfc1 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs @@ -1,119 +1,118 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Security.Claims; -namespace CodeBeam.UltimateAuth.Client.Authentication +namespace CodeBeam.UltimateAuth.Client.Authentication; + +/// +/// 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 { - /// - /// 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() { } + private UAuthState() { } - public bool IsAuthenticated { get; private set; } + public bool IsAuthenticated { get; private set; } - public UserKey? UserKey { get; private set; } + public UserKey? UserKey { get; private set; } - public string? TenantId { get; private set; } + public TenantKey Tenant { get; private set; } - /// - /// When this authentication snapshot was created. - /// - public DateTimeOffset? AuthenticatedAt { get; private set; } + /// + /// When this authentication snapshot was created. + /// + public DateTimeOffset? AuthenticatedAt { get; private set; } - /// - /// When this snapshot was last validated or refreshed. - /// - public DateTimeOffset? LastValidatedAt { get; private set; } + /// + /// When this snapshot was last validated or refreshed. + /// + 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; } + /// + /// Indicates whether the snapshot may be stale + /// (e.g. after navigation, reload, or time-based heuristics). + /// + public bool IsStale { get; private set; } - public ClaimsSnapshot Claims { get; private set; } = ClaimsSnapshot.Empty; + public ClaimsSnapshot Claims { get; private set; } = ClaimsSnapshot.Empty; - public event Action? Changed; + public event Action? Changed; - public static UAuthState Anonymous() => new(); + public static UAuthState Anonymous() => new(); - internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt) + internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt) + { + if (string.IsNullOrWhiteSpace(snapshot.UserKey)) { - if (string.IsNullOrWhiteSpace(snapshot.UserId)) - { - Clear(); - return; - } + Clear(); + return; + } - UserKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString(snapshot.UserId); - TenantId = snapshot.TenantId; - Claims = snapshot.Claims; + UserKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString(snapshot.UserKey); + Tenant = snapshot.Tenant; + Claims = snapshot.Claims; - IsAuthenticated = true; + IsAuthenticated = true; - AuthenticatedAt = snapshot.AuthenticatedAt; - LastValidatedAt = validatedAt; - IsStale = false; + AuthenticatedAt = snapshot.AuthenticatedAt; + LastValidatedAt = validatedAt; + IsStale = false; - Changed?.Invoke(UAuthStateChangeReason.Authenticated); - } + Changed?.Invoke(UAuthStateChangeReason.Authenticated); + } - internal void MarkValidated(DateTimeOffset now) - { - if (!IsAuthenticated) - return; + internal void MarkValidated(DateTimeOffset now) + { + if (!IsAuthenticated) + return; - LastValidatedAt = now; - IsStale = false; + LastValidatedAt = now; + IsStale = false; - Changed?.Invoke(UAuthStateChangeReason.Validated); - } - - internal void MarkStale() - { - if (!IsAuthenticated) - return; + Changed?.Invoke(UAuthStateChangeReason.Validated); + } - IsStale = true; - Changed?.Invoke(UAuthStateChangeReason.MarkedStale); - } + internal void MarkStale() + { + if (!IsAuthenticated) + return; - internal void Clear() - { - Claims = ClaimsSnapshot.Empty; + IsStale = true; + Changed?.Invoke(UAuthStateChangeReason.MarkedStale); + } - UserKey = null; - TenantId = null; - IsAuthenticated = false; + internal void Clear() + { + Claims = ClaimsSnapshot.Empty; - AuthenticatedAt = null; - LastValidatedAt = null; - IsStale = false; + UserKey = null; + IsAuthenticated = false; - Changed?.Invoke(UAuthStateChangeReason.Cleared); - } + AuthenticatedAt = null; + LastValidatedAt = null; + IsStale = false; - /// - /// Creates a ClaimsPrincipal view for ASP.NET / Blazor integration. - /// - public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = "UltimateAuth") - { - if (!IsAuthenticated) - return new ClaimsPrincipal(new ClaimsIdentity()); + Changed?.Invoke(UAuthStateChangeReason.Cleared); + } - var identity = new ClaimsIdentity( - Claims.AsDictionary() - .Select(kv => new Claim(kv.Key, kv.Value)), - authenticationType); + /// + /// Creates a ClaimsPrincipal view for ASP.NET / Blazor integration. + /// + public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = "UltimateAuth") + { + if (!IsAuthenticated) + return new ClaimsPrincipal(new ClaimsIdentity()); - return new ClaimsPrincipal(identity); - } + var identity = new ClaimsIdentity( + Claims.AsDictionary() + .Select(kv => new Claim(kv.Key, kv.Value)), + authenticationType); + return new ClaimsPrincipal(identity); } + } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs index b2b72dde..ad2fa368 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Client.Authentication +namespace CodeBeam.UltimateAuth.Client.Authentication; + +public enum UAuthStateChangeReason { - public enum UAuthStateChangeReason - { - Authenticated, - Validated, - MarkedStale, - Cleared - } + Authenticated, + Validated, + MarkedStale, + Cleared } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs new file mode 100644 index 00000000..55e3f7e1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs @@ -0,0 +1,54 @@ +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Client.Authentication; + +internal sealed class UAuthStateManager : IUAuthStateManager +{ + private readonly IUAuthClient _client; + private readonly IClock _clock; + private readonly IUAuthClientBootstrapper _bootstrapper; + + public UAuthState State { get; } = UAuthState.Anonymous(); + + public UAuthStateManager(IUAuthClient client, IClock clock, IUAuthClientBootstrapper bootstrapper) + { + _client = client; + _clock = clock; + _bootstrapper = bootstrapper; + } + + public async Task EnsureAsync(CancellationToken ct = default) + { + if (State.IsAuthenticated && !State.IsStale) + return; + + await _bootstrapper.EnsureStartedAsync(); + var result = await _client.Flows.ValidateAsync(); + + if (!result.IsValid || result.Snapshot == null) + { + State.Clear(); + return; + } + + State.ApplySnapshot(result.Snapshot, _clock.UtcNow); + } + + public Task OnLoginAsync() + { + State.MarkStale(); + return Task.CompletedTask; + } + + public Task OnLogoutAsync() + { + State.Clear(); + return Task.CompletedTask; + } + + public void MarkStale() + { + State.MarkStale(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs index 8dbab50d..b747c717 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs @@ -3,166 +3,164 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; using Microsoft.JSInterop; -namespace CodeBeam.UltimateAuth.Client +namespace CodeBeam.UltimateAuth.Client; + +public partial class UALoginForm { - public partial class UALoginForm - { - [Inject] IDeviceIdProvider DeviceIdProvider { get; set; } = null!; - private DeviceId? _deviceId; + [Inject] IDeviceIdProvider DeviceIdProvider { get; set; } = null!; + private DeviceId? _deviceId; - [Inject] - IHubCredentialResolver HubCredentialResolver { get; set; } = null!; + [Inject] + IHubCredentialResolver HubCredentialResolver { get; set; } = null!; - [Inject] - IHubFlowReader HubFlowReader { get; set; } = null!; + [Inject] + IHubFlowReader HubFlowReader { get; set; } = null!; - [Inject] - IHubCapabilities HubCapabilities { get; set; } = null!; + [Inject] + IHubCapabilities HubCapabilities { get; set; } = null!; - [Parameter] - public string? Identifier { get; set; } + [Parameter] + public string? Identifier { get; set; } - [Parameter] - public string? Secret { get; set; } + [Parameter] + public string? Secret { get; set; } - [Parameter] - public string? Endpoint { get; set; } + [Parameter] + public string? Endpoint { get; set; } - [Parameter] - public string? ReturnUrl { get; set; } + [Parameter] + public string? ReturnUrl { get; set; } - //[Parameter] - //public IHubCredentialResolver? HubCredentialResolver { get; set; } + //[Parameter] + //public IHubCredentialResolver? HubCredentialResolver { get; set; } - //[Parameter] - //public IHubFlowReader? HubFlowReader { get; set; } + //[Parameter] + //public IHubFlowReader? HubFlowReader { get; set; } - [Parameter] - public HubSessionId? HubSessionId { get; set; } + [Parameter] + public HubSessionId? HubSessionId { get; set; } - [Parameter] - public UAuthLoginType LoginType { get; set; } = UAuthLoginType.Password; + [Parameter] + public UAuthLoginType LoginType { get; set; } = UAuthLoginType.Password; - [Parameter] - public RenderFragment? ChildContent { get; set; } + [Parameter] + public RenderFragment? ChildContent { get; set; } - [Parameter] - public bool AllowEnterKeyToSubmit { get; set; } = true; + [Parameter] + public bool AllowEnterKeyToSubmit { get; set; } = true; - private ElementReference _form; + private ElementReference _form; - private HubCredentials? _credentials; - private HubFlowState? _flow; - protected override async Task OnParametersSetAsync() - { - await base.OnParametersSetAsync(); - - await ReloadCredentialsAsync(); - await ReloadStateAsync(); + private HubCredentials? _credentials; + private HubFlowState? _flow; + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); - 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."); - } + await ReloadCredentialsAsync(); + await ReloadStateAsync(); - //if (LoginType == UAuthLoginType.Pkce && EffectiveHubSessionId is null) - //{ - // throw new InvalidOperationException("PKCE login requires an active Hub flow. " + - // "No 'hub' query parameter was found." - // ); - //} + 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; + //if (LoginType == UAuthLoginType.Pkce && EffectiveHubSessionId is null) + //{ + // throw new InvalidOperationException("PKCE login requires an active Hub flow. " + + // "No 'hub' query parameter was found." + // ); + //} + } - _deviceId = await DeviceIdProvider.GetOrCreateAsync(); - StateHasChanged(); - } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; - public async Task ReloadCredentialsAsync() - { - if (LoginType != UAuthLoginType.Pkce) - return; + _deviceId = await DeviceIdProvider.GetOrCreateAsync(); + StateHasChanged(); + } - if (HubCredentialResolver is null || EffectiveHubSessionId is null) - return; + public async Task ReloadCredentialsAsync() + { + if (LoginType != UAuthLoginType.Pkce) + return; - _credentials = await HubCredentialResolver.ResolveAsync(EffectiveHubSessionId.Value); - } + if (HubCredentialResolver is null || EffectiveHubSessionId is null) + return; - public async Task ReloadStateAsync() - { - if (LoginType != UAuthLoginType.Pkce || EffectiveHubSessionId is null || HubFlowReader is null) - return; + _credentials = await HubCredentialResolver.ResolveAsync(EffectiveHubSessionId.Value); + } - _flow = await HubFlowReader.GetStateAsync(EffectiveHubSessionId.Value); - } + public async Task ReloadStateAsync() + { + if (LoginType != UAuthLoginType.Pkce || EffectiveHubSessionId is null || HubFlowReader is null) + return; - public async Task SubmitAsync() - { - if (_form.Context is null) - throw new InvalidOperationException("Form is not yet rendered. Call SubmitAsync after OnAfterRender."); + _flow = await HubFlowReader.GetStateAsync(EffectiveHubSessionId.Value); + } - await JS.InvokeVoidAsync("uauth.submitForm", _form); - } + public async Task SubmitAsync() + { + if (_form.Context is null) + throw new InvalidOperationException("Form is not yet rendered. Call SubmitAsync after OnAfterRender."); - private string ClientProfileValue => CoreOptions.Value.ClientProfile.ToString(); + await JS.InvokeVoidAsync("uauth.submitForm", _form); + } + + private string ClientProfileValue => CoreOptions.Value.ClientProfile.ToString(); - private string EffectiveEndpoint => LoginType == UAuthLoginType.Pkce - ? Options.Value.Endpoints.PkceComplete - : Options.Value.Endpoints.Login; + private string EffectiveEndpoint => LoginType == UAuthLoginType.Pkce + ? Options.Value.Endpoints.PkceComplete + : Options.Value.Endpoints.Login; - private string ResolvedEndpoint + private string ResolvedEndpoint + { + get { - get - { - var loginPath = string.IsNullOrWhiteSpace(Endpoint) - ? EffectiveEndpoint - : Endpoint; + var loginPath = string.IsNullOrWhiteSpace(Endpoint) + ? EffectiveEndpoint + : Endpoint; - var baseUrl = UAuthUrlBuilder.Combine(Options.Value.Endpoints.Authority, loginPath); - var returnUrl = EffectiveReturnUrl; + var baseUrl = UAuthUrlBuilder.Combine(Options.Value.Endpoints.Authority, loginPath); + var returnUrl = EffectiveReturnUrl; - if (string.IsNullOrWhiteSpace(returnUrl)) - return baseUrl; + if (string.IsNullOrWhiteSpace(returnUrl)) + return baseUrl; - return $"{baseUrl}?{(_credentials != null ? "hub=" + EffectiveHubSessionId + "&" : null)}returnUrl={Uri.EscapeDataString(returnUrl)}"; - } + return $"{baseUrl}?{(_credentials != null ? "hub=" + EffectiveHubSessionId + "&" : null)}returnUrl={Uri.EscapeDataString(returnUrl)}"; } + } - private string EffectiveReturnUrl => !string.IsNullOrWhiteSpace(ReturnUrl) - ? ReturnUrl - : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl ?? string.Empty : Navigation.Uri; + private string EffectiveReturnUrl => !string.IsNullOrWhiteSpace(ReturnUrl) + ? ReturnUrl + : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl ?? string.Empty : Navigation.Uri; - private HubSessionId? EffectiveHubSessionId + private HubSessionId? EffectiveHubSessionId + { + get { - get - { - if (HubSessionId is not null) - return HubSessionId; - - var uri = Navigation.ToAbsoluteUri(Navigation.Uri); - var query = QueryHelpers.ParseQuery(uri.Query); + if (HubSessionId is not null) + return HubSessionId; - if (query.TryGetValue("hub", out var hubValue) && CodeBeam.UltimateAuth.Core.Domain.HubSessionId.TryParse(hubValue, out var parsed)) - { - return parsed; - } + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); - return null; + if (query.TryGetValue("hub", out var hubValue) && CodeBeam.UltimateAuth.Core.Domain.HubSessionId.TryParse(hubValue, out var parsed)) + { + return parsed; } - } + return null; + } } + } diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs index f66787af..c02a9c5a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs @@ -1,46 +1,45 @@ using CodeBeam.UltimateAuth.Client.Authentication; using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Client.Components +namespace CodeBeam.UltimateAuth.Client.Components; + +public partial class UAuthAuthenticationState { - public partial class UAuthAuthenticationState - { - private bool _initialized; - private UAuthState _uauthState; + private bool _initialized; + private UAuthState _uauthState = UAuthState.Anonymous(); - [Parameter] - public RenderFragment ChildContent { get; set; } = default!; + [Parameter] + public RenderFragment ChildContent { get; set; } = default!; - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender) - return; + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; - if (_initialized) - return; + if (_initialized) + return; - _initialized = true; - //await Bootstrapper.EnsureStartedAsync(); - await StateManager.EnsureAsync(); - _uauthState = StateManager.State; + _initialized = true; + //await Bootstrapper.EnsureStartedAsync(); + await StateManager.EnsureAsync(); + _uauthState = StateManager.State; - StateManager.State.Changed += OnStateChanged; - } + StateManager.State.Changed += OnStateChanged; + } - private void OnStateChanged(UAuthStateChangeReason _) + private void OnStateChanged(UAuthStateChangeReason _) + { + //StateManager.EnsureAsync(); + if (_ == UAuthStateChangeReason.MarkedStale) { - //StateManager.EnsureAsync(); - if (_ == UAuthStateChangeReason.MarkedStale) - { - StateManager.EnsureAsync(); - } - _uauthState = StateManager.State; - InvokeAsync(StateHasChanged); + StateManager.EnsureAsync(); } + _uauthState = StateManager.State; + InvokeAsync(StateHasChanged); + } - public void Dispose() - { - StateManager.State.Changed -= OnStateChanged; - } + public void Dispose() + { + StateManager.State.Changed -= OnStateChanged; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs index c3d28274..8bbcd7e2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs @@ -1,43 +1,41 @@ -using CodeBeam.UltimateAuth.Client.Diagnostics; -using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Client +namespace CodeBeam.UltimateAuth.Client; + +// TODO: Add CircuitHandler to manage start/stop of coordinator in server-side Blazor +public partial class UAuthClientProvider : ComponentBase, IAsyncDisposable { - // TODO: Add CircuitHandler to manage start/stop of coordinator in server-side Blazor - public partial class UAuthClientProvider : ComponentBase, IAsyncDisposable + private bool _started; + + [Parameter] + public EventCallback OnReauthRequired { get; set; } + + protected override async Task OnInitializedAsync() + { + Coordinator.ReauthRequired += HandleReauthRequired; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _started) + return; + + _started = true; + // TODO: Add device id auto creation for MVC, this is only for blazor. + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); + await BrowserUAuthBridge.SetDeviceIdAsync(deviceId.Value); + await Coordinator.StartAsync(); + StateHasChanged(); + } + + private async void HandleReauthRequired() + { + if (OnReauthRequired.HasDelegate) + await OnReauthRequired.InvokeAsync(); + } + + public async ValueTask DisposeAsync() { - private bool _started; - - [Parameter] - public EventCallback OnReauthRequired { get; set; } - - protected override async Task OnInitializedAsync() - { - Coordinator.ReauthRequired += HandleReauthRequired; - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender || _started) - return; - - _started = true; - // TODO: Add device id auto creation for MVC, this is only for blazor. - var deviceId = await DeviceIdProvider.GetOrCreateAsync(); - await BrowserUAuthBridge.SetDeviceIdAsync(deviceId.Value); - await Coordinator.StartAsync(); - StateHasChanged(); - } - - private async void HandleReauthRequired() - { - if (OnReauthRequired.HasDelegate) - await OnReauthRequired.InvokeAsync(); - } - - public async ValueTask DisposeAsync() - { - await Coordinator.StopAsync(); - } + await Coordinator.StopAsync(); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs index 58a398b2..3b079766 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Client.Contracts +namespace CodeBeam.UltimateAuth.Client.Contracts; + +public enum CoordinatorTerminationReason { - public enum CoordinatorTerminationReason - { - None = 0, - ReauthRequired = 1 - } + None = 0, + ReauthRequired = 1 } diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs index a8fcad43..a22da4f0 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Client.Contracts +namespace CodeBeam.UltimateAuth.Client.Contracts; + +internal sealed class PkceClientState { - internal sealed class PkceClientState - { - public string Verifier { get; init; } = default!; - public string AuthorizationCode { get; init; } = default!; - } + public string Verifier { get; init; } = default!; + public string AuthorizationCode { get; init; } = default!; } diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs index d60efdbe..5e35d6ac 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs @@ -1,11 +1,10 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Contracts +namespace CodeBeam.UltimateAuth.Client.Contracts; + +public sealed record RefreshResult { - public sealed record RefreshResult - { - public bool Ok { get; init; } - public int Status { get; init; } - public RefreshOutcome Outcome { get; init; } - } + public bool Ok { get; init; } + public int Status { get; init; } + public RefreshOutcome Outcome { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs index 9e823eef..322f397c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Client.Contracts +namespace CodeBeam.UltimateAuth.Client.Contracts; + +public enum StorageScope { - public enum StorageScope - { - Session, - Local - } + Session, + Local } diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs index 867cfe8c..747acb6d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs @@ -1,12 +1,11 @@ using System.Text.Json; -namespace CodeBeam.UltimateAuth.Client.Contracts +namespace CodeBeam.UltimateAuth.Client.Contracts; + +public sealed class UAuthTransportResult { - public sealed class UAuthTransportResult - { - public bool Ok { get; init; } - public int Status { get; init; } - public string? RefreshOutcome { get; init; } - public JsonElement? Body { get; init; } - } + public bool Ok { get; init; } + public int Status { get; init; } + public string? RefreshOutcome { get; init; } + public JsonElement? Body { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs index b19b0dc7..033d82f0 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Device +namespace CodeBeam.UltimateAuth.Client.Device; + +public interface IDeviceIdGenerator { - public interface IDeviceIdGenerator - { - DeviceId Generate(); - } + DeviceId Generate(); } diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs index f8983b5a..5bc079ef 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Device +namespace CodeBeam.UltimateAuth.Client.Device; + +public interface IDeviceIdProvider { - public interface IDeviceIdProvider - { - ValueTask GetOrCreateAsync(CancellationToken ct = default); - } + ValueTask GetOrCreateAsync(CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs index c3555525..c91457d3 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Client.Device +namespace CodeBeam.UltimateAuth.Client.Device; + +public interface IDeviceIdStorage { - public interface IDeviceIdStorage - { - ValueTask LoadAsync(CancellationToken ct = default); - ValueTask SaveAsync(string deviceId, CancellationToken ct = default); - } + ValueTask LoadAsync(CancellationToken ct = default); + ValueTask SaveAsync(string deviceId, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs b/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs rename to src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs index ccca8c95..1cf9fb4a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Client.Devices; -public sealed class DefaultDeviceIdGenerator : IDeviceIdGenerator +public sealed class UAuthDeviceIdGenerator : IDeviceIdGenerator { public DeviceId Generate() { diff --git a/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs b/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs rename to src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs index c1f504a8..b7152b00 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/DefaultDeviceIdProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs @@ -3,14 +3,14 @@ namespace CodeBeam.UltimateAuth.Client.Devices; -public sealed class DefaultDeviceIdProvider : IDeviceIdProvider +public sealed class UAuthDeviceIdProvider : IDeviceIdProvider { private readonly IDeviceIdStorage _storage; private readonly IDeviceIdGenerator _generator; private DeviceId? _cached; - public DefaultDeviceIdProvider(IDeviceIdStorage storage, IDeviceIdGenerator generator) + public UAuthDeviceIdProvider(IDeviceIdStorage storage, IDeviceIdGenerator generator) { _storage = storage; _generator = generator; diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs index a6e3e639..8e9defcb 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs @@ -1,15 +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 - }; - } +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/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..0ae74a5e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,138 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Client.Devices; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Client.Utilities; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Configuration; +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 UltimateAuth client services using configuration binding + /// (e.g. appsettings.json). + /// + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, IConfiguration configurationSection) + { + services.Configure(configurationSection); + return services.AddUltimateAuthClientInternal(); + } + + /// + /// Registers UltimateAuth client services using programmatic configuration. + /// + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, Action configure) + { + services.Configure(configure); + return services.AddUltimateAuthClientInternal(); + } + + /// + /// Registers UltimateAuth client services with default (empty) configuration. + /// + /// Intended for advanced scenarios where configuration is fully controlled + /// by the hosting application or overridden later. + /// + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services) + { + services.Configure(_ => { }); + 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) + { + // Options validation can be added here later if needed + // services.AddSingleton, ...>(); + + services.AddSingleton(); + services.AddSingleton, UAuthOptionsPostConfigure>(); + services.TryAddSingleton(); + + //services.PostConfigure(o => + //{ + // if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) + // return; + + // using var sp = services.BuildServiceProvider(); + // var detector = sp.GetRequiredService(); + // o.ClientProfile = detector.Detect(sp); + //}); + + services.PostConfigure(o => + { + o.Refresh.Interval ??= TimeSpan.FromMinutes(5); + }); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddScoped(sp => + { + var core = sp.GetRequiredService>().Value; + + return core.ClientProfile == UAuthClientProfile.BlazorServer + ? sp.GetRequiredService() + : sp.GetRequiredService(); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs deleted file mode 100644 index db0e6428..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs +++ /dev/null @@ -1,138 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using CodeBeam.UltimateAuth.Client.Authentication; -using CodeBeam.UltimateAuth.Client.Device; -using CodeBeam.UltimateAuth.Client.Devices; -using CodeBeam.UltimateAuth.Client.Diagnostics; -using CodeBeam.UltimateAuth.Client.Infrastructure; -using CodeBeam.UltimateAuth.Client.Options; -using CodeBeam.UltimateAuth.Client.Runtime; -using CodeBeam.UltimateAuth.Client.Services; -using CodeBeam.UltimateAuth.Client.Utilities; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.Extensions.Configuration; -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 UltimateAuthClientServiceCollectionExtensions - { - /// - /// Registers UltimateAuth client services using configuration binding - /// (e.g. appsettings.json). - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, IConfiguration configurationSection) - { - services.Configure(configurationSection); - return services.AddUltimateAuthClientInternal(); - } - - /// - /// Registers UltimateAuth client services using programmatic configuration. - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, Action configure) - { - services.Configure(configure); - return services.AddUltimateAuthClientInternal(); - } - - /// - /// Registers UltimateAuth client services with default (empty) configuration. - /// - /// Intended for advanced scenarios where configuration is fully controlled - /// by the hosting application or overridden later. - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services) - { - services.Configure(_ => { }); - 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) - { - // Options validation can be added here later if needed - // services.AddSingleton, ...>(); - - services.AddSingleton(); - services.AddSingleton, UAuthOptionsPostConfigure>(); - services.TryAddSingleton(); - - //services.PostConfigure(o => - //{ - // if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) - // return; - - // using var sp = services.BuildServiceProvider(); - // var detector = sp.GetRequiredService(); - // o.ClientProfile = detector.Detect(sp); - //}); - - services.PostConfigure(o => - { - o.Refresh.Interval ??= TimeSpan.FromMinutes(5); - }); - - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - - services.AddScoped(sp => - { - var core = sp.GetRequiredService>().Value; - - return core.ClientProfile == UAuthClientProfile.BlazorServer - ? sp.GetRequiredService() - : sp.GetRequiredService(); - }); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - services.AddScoped>(sp => sp.GetRequiredService()); - - return services; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs index 0b0d06ed..0857f31b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs @@ -6,98 +6,97 @@ using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class BlazorServerSessionCoordinator : ISessionCoordinator { - internal sealed class BlazorServerSessionCoordinator : ISessionCoordinator - { - private readonly IUAuthClient _client; - private readonly NavigationManager _navigation; - private readonly UAuthClientOptions _options; - private readonly UAuthClientDiagnostics _diagnostics; + private readonly IUAuthClient _client; + private readonly NavigationManager _navigation; + private readonly UAuthClientOptions _options; + private readonly UAuthClientDiagnostics _diagnostics; - private PeriodicTimer? _timer; - private CancellationTokenSource? _cts; + private PeriodicTimer? _timer; + private CancellationTokenSource? _cts; - public event Action? ReauthRequired; + public event Action? ReauthRequired; - public BlazorServerSessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics) - { - _client = client; - _navigation = navigation; - _options = options.Value; - _diagnostics = diagnostics; - } + public BlazorServerSessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics) + { + _client = client; + _navigation = navigation; + _options = options.Value; + _diagnostics = diagnostics; + } - public async Task StartAsync(CancellationToken cancellationToken = default) - { - if (_timer is not null) - return; + public async Task StartAsync(CancellationToken cancellationToken = default) + { + if (_timer is not null) + return; - _diagnostics.MarkStarted(); - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var interval = _options.Refresh.Interval ?? TimeSpan.FromMinutes(5); - _timer = new PeriodicTimer(interval); + _diagnostics.MarkStarted(); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var interval = _options.Refresh.Interval ?? TimeSpan.FromMinutes(5); + _timer = new PeriodicTimer(interval); - _ = RunAsync(_cts.Token); - } + _ = RunAsync(_cts.Token); + } - private async Task RunAsync(CancellationToken ct) + private async Task RunAsync(CancellationToken ct) + { + try { - try + while (await _timer!.WaitForNextTickAsync(ct)) { - while (await _timer!.WaitForNextTickAsync(ct)) + _diagnostics.MarkAutomaticRefresh(); + var result = await _client.Flows.RefreshAsync(isAuto: true); + + switch (result.Outcome) { - _diagnostics.MarkAutomaticRefresh(); - var result = await _client.Flows.RefreshAsync(isAuto: true); - - switch (result.Outcome) - { - case RefreshOutcome.Touched: - break; - - case RefreshOutcome.NoOp: - break; - - case RefreshOutcome.None: - break; - - case RefreshOutcome.ReauthRequired: - switch (_options.Reauth.Behavior) - { - case ReauthBehavior.RedirectToLogin: - _navigation.NavigateTo(_options.Reauth.LoginPath, forceLoad: true); - break; - - case ReauthBehavior.RaiseEvent: - ReauthRequired?.Invoke(); - break; - - case ReauthBehavior.None: - break; - } - _diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); - return; - } + case RefreshOutcome.Touched: + break; + + case RefreshOutcome.NoOp: + break; + + case RefreshOutcome.None: + break; + + case RefreshOutcome.ReauthRequired: + switch (_options.Reauth.Behavior) + { + case ReauthBehavior.RedirectToLogin: + _navigation.NavigateTo(_options.Reauth.LoginPath, forceLoad: true); + break; + + case ReauthBehavior.RaiseEvent: + ReauthRequired?.Invoke(); + break; + + case ReauthBehavior.None: + break; + } + _diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + return; } } - catch (OperationCanceledException) - { - // expected - } } - - public Task StopAsync() + catch (OperationCanceledException) { - _diagnostics.MarkStopped(); - _cts?.Cancel(); - _timer?.Dispose(); - _timer = null; - return Task.CompletedTask; + // expected } + } - public async ValueTask DisposeAsync() - { - await StopAsync(); - } + public Task StopAsync() + { + _diagnostics.MarkStopped(); + _cts?.Cancel(); + _timer?.Dispose(); + _timer = null; + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs index b62442d2..e270af2a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs @@ -1,30 +1,29 @@ using CodeBeam.UltimateAuth.Client.Contracts; using Microsoft.JSInterop; -namespace CodeBeam.UltimateAuth.Client.Utilities +namespace CodeBeam.UltimateAuth.Client.Utilities; + +public sealed class BrowserStorage : IBrowserStorage { - public sealed class BrowserStorage : IBrowserStorage - { - private readonly IJSRuntime _js; + private readonly IJSRuntime _js; - public BrowserStorage(IJSRuntime js) - { - _js = js; - } + public BrowserStorage(IJSRuntime js) + { + _js = js; + } - public ValueTask SetAsync(StorageScope scope, string key, string value) - => _js.InvokeVoidAsync("uauth.storage.set", Scope(scope), key, value); + 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 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 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); + 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"; - } + private static string Scope(StorageScope scope) + => scope == StorageScope.Local ? "local" : "session"; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs index f93dd98d..484d9b8f 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs @@ -1,15 +1,14 @@ using CodeBeam.UltimateAuth.Client.Contracts; -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public interface IUAuthRequestClient { - public interface IUAuthRequestClient - { - Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); - Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); - Task SendFormForJsonAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + Task SendFormForJsonAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); - Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default); - } + Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs index 24042f5c..002a7c27 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class NoOpHubCapabilities : IHubCapabilities { - internal sealed class NoOpHubCapabilities : IHubCapabilities - { - public bool SupportsPkce => false; - } + public bool SupportsPkce => false; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs index 658a8653..69c2b989 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class NoOpHubCredentialResolver : IHubCredentialResolver { - internal sealed class NoOpHubCredentialResolver : IHubCredentialResolver - { - public Task ResolveAsync(HubSessionId sessionId, CancellationToken ct = default) => Task.FromResult(null); - } + public Task ResolveAsync(HubSessionId sessionId, CancellationToken ct = default) => Task.FromResult(null); } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs index 9b6a7768..9cdfa747 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class NoOpHubFlowReader : IHubFlowReader { - internal sealed class NoOpHubFlowReader : IHubFlowReader - { - public Task GetStateAsync(HubSessionId sessionId, CancellationToken ct = default) => Task.FromResult(null); - } + public Task GetStateAsync(HubSessionId sessionId, CancellationToken ct = default) => Task.FromResult(null); } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs index 5784d106..800515b8 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs @@ -1,18 +1,14 @@ using CodeBeam.UltimateAuth.Client.Abstractions; -namespace CodeBeam.UltimateAuth.Client.Infrastructure -{ - internal sealed class NoOpSessionCoordinator : ISessionCoordinator - { - public event Action? ReauthRequired; - - public Task StartAsync(CancellationToken cancellationToken = default) - => Task.CompletedTask; +namespace CodeBeam.UltimateAuth.Client.Infrastructure; - public Task StopAsync() - => Task.CompletedTask; +internal sealed class NoOpSessionCoordinator : ISessionCoordinator +{ +#pragma warning disable CS0067 + public event Action? ReauthRequired; +#pragma warning restore CS0067 - public ValueTask DisposeAsync() - => ValueTask.CompletedTask; - } + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StopAsync() => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs index b5ccf9a1..88cb7aa5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs @@ -1,21 +1,20 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class RefreshOutcomeParser { - internal static class RefreshOutcomeParser + public static RefreshOutcome Parse(string? value) { - public static RefreshOutcome Parse(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return RefreshOutcome.None; + if (string.IsNullOrWhiteSpace(value)) + return RefreshOutcome.None; - return value switch - { - "no-op" => RefreshOutcome.NoOp, - "touched" => RefreshOutcome.Touched, - "reauth-required" => RefreshOutcome.ReauthRequired, - _ => RefreshOutcome.None - }; - } + return value switch + { + "no-op" => RefreshOutcome.NoOp, + "touched" => RefreshOutcome.Touched, + "reauth-required" => RefreshOutcome.ReauthRequired, + _ => RefreshOutcome.None + }; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs index beb15f80..bedb3795 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs @@ -5,75 +5,74 @@ using Microsoft.JSInterop; // TODO: Add fluent helper API like RequiredOk -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class UAuthRequestClient : IUAuthRequestClient { - internal sealed class UAuthRequestClient : IUAuthRequestClient + private readonly IJSRuntime _js; + private UAuthOptions _coreOptions; + + public UAuthRequestClient(IJSRuntime js, IOptions coreOptions) { - private readonly IJSRuntime _js; - private UAuthOptions _coreOptions; + _js = js; + _coreOptions = coreOptions.Value; + } - public UAuthRequestClient(IJSRuntime js, IOptions coreOptions) - { - _js = js; - _coreOptions = coreOptions.Value; - } + public Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - public Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + return _js.InvokeVoidAsync("uauth.post", ct, new { - ct.ThrowIfCancellationRequested(); + url = endpoint, + mode = "navigate", + data = form, + clientProfile = _coreOptions.ClientProfile.ToString() + }).AsTask(); + } - return _js.InvokeVoidAsync("uauth.post", ct, new - { - url = endpoint, - mode = "navigate", - data = form, - clientProfile = _coreOptions.ClientProfile.ToString() - }).AsTask(); - } + public async Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - public async Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + var result = await _js.InvokeAsync("uauth.post", ct, new { - ct.ThrowIfCancellationRequested(); + url = endpoint, + mode = "fetch", + expectJson = false, + data = form, + clientProfile = _coreOptions.ClientProfile.ToString() + }); + + return result; + } + + public async Task SendFormForJsonAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - var result = await _js.InvokeAsync("uauth.post", ct, new + var postData = form ?? new Dictionary(); + return await _js.InvokeAsync("uauth.post", ct, + new { url = endpoint, mode = "fetch", - expectJson = false, - data = form, + expectJson = true, + data = postData, clientProfile = _coreOptions.ClientProfile.ToString() }); + } - return result; - } - - public async Task SendFormForJsonAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var postData = form ?? new Dictionary(); - return await _js.InvokeAsync("uauth.post", ct, - new - { - url = endpoint, - mode = "fetch", - expectJson = true, - data = postData, - clientProfile = _coreOptions.ClientProfile.ToString() - }); - } + public async Task SendJsonAsync(string endpoint, object? payload = default, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - public async Task SendJsonAsync(string endpoint, object? payload = default, CancellationToken ct = default) + return await _js.InvokeAsync("uauth.postJson", ct, new { - ct.ThrowIfCancellationRequested(); - - return await _js.InvokeAsync("uauth.postJson", ct, new - { - url = endpoint, - payload = payload, - clientProfile = _coreOptions.ClientProfile.ToString() - }); - } - + url = endpoint, + payload = payload, + clientProfile = _coreOptions.ClientProfile.ToString() + }); } + } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index 245575ee..cf4ebfea 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -2,50 +2,49 @@ using CodeBeam.UltimateAuth.Core.Contracts; using System.Text.Json; -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class UAuthResultMapper { - internal static class UAuthResultMapper + public static UAuthResult FromJson(UAuthTransportResult raw) { - public static UAuthResult FromJson(UAuthTransportResult raw) + if (!raw.Ok) { - if (!raw.Ok) - { - return new UAuthResult - { - Ok = false, - Status = raw.Status - }; - } - - if (raw.Body is null) + return new UAuthResult { - return new UAuthResult - { - Ok = true, - Status = raw.Status, - Value = default - }; - } - - var value = raw.Body.Value.Deserialize( - new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + Ok = false, + Status = raw.Status + }; + } + if (raw.Body is null) + { return new UAuthResult { Ok = true, Status = raw.Status, - Value = value + Value = default }; } - public static UAuthResult FromStatus(UAuthTransportResult raw) - => new() + var value = raw.Body.Value.Deserialize( + new JsonSerializerOptions { - Ok = raw.Ok, - Status = raw.Status - }; + PropertyNameCaseInsensitive = true + }); + + return new UAuthResult + { + Ok = true, + Status = raw.Status, + Value = value + }; } + + public static UAuthResult FromStatus(UAuthTransportResult raw) + => new() + { + Ok = raw.Ok, + Status = raw.Status + }; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs index 6f88564f..2d4cbb74 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class UAuthUrlBuilder { - internal static class UAuthUrlBuilder + public static string Combine(string authority, string relative) { - public static string Combine(string authority, string relative) - { - return authority.TrimEnd('/') + "/" + relative.TrimStart('/'); - } + return authority.TrimEnd('/') + "/" + relative.TrimStart('/'); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs index 4f8f3ba8..2f82cd8f 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs @@ -1,27 +1,25 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Client.Options -{ - public sealed class PkceLoginOptions - { - /// - /// Enables PKCE login support. - /// - public bool Enabled { get; set; } = true; +namespace CodeBeam.UltimateAuth.Client.Options; - public string? ReturnUrl { get; init; } +public sealed class PkceLoginOptions +{ + /// + /// Enables PKCE login support. + /// + public bool Enabled { get; set; } = true; - /// - /// Called after authorization_code is issued, - /// before redirecting to the Hub. - /// - public Func? OnAuthorized { get; init; } + public string? ReturnUrl { get; init; } - /// - /// If false, BeginPkceAsync will NOT redirect automatically. - /// Caller is responsible for navigation. - /// - public bool AutoRedirect { get; init; } = true; - } + /// + /// Called after authorization_code is issued, + /// before redirecting to the Hub. + /// + public Func? OnAuthorized { get; init; } + /// + /// If false, BeginPkceAsync will NOT redirect automatically. + /// Caller is responsible for navigation. + /// + public bool AutoRedirect { get; init; } = true; } diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index 94f0af80..0aabb867 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -1,75 +1,74 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Options +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientOptions { - public sealed class UAuthClientOptions - { - public AuthEndpointOptions Endpoints { get; set; } = new(); - public LoginOptions Login { get; set; } = new(); - public UAuthClientRefreshOptions Refresh { get; set; } = new(); - public ReauthOptions Reauth { get; init; } = new(); - } + public AuthEndpointOptions Endpoints { get; set; } = new(); + public LoginOptions Login { get; set; } = new(); + public UAuthClientRefreshOptions Refresh { get; set; } = new(); + public ReauthOptions Reauth { get; init; } = new(); +} - public sealed class AuthEndpointOptions - { - /// - /// Base URL of UAuthHub (e.g. https://localhost:6110) - /// - public string Authority { get; set; } = "/auth"; +public sealed class AuthEndpointOptions +{ + /// + /// Base URL of UAuthHub (e.g. https://localhost:6110) + /// + public string Authority { get; set; } = "/auth"; - public string Login { get; set; } = "/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 PkceComplete { get; set; } = "/pkce/complete"; - public string HubLoginPath { get; set; } = "/uauthhub/login"; - } + public string Login { get; set; } = "/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 PkceComplete { get; set; } = "/pkce/complete"; + public string HubLoginPath { get; set; } = "/uauthhub/login"; +} - public sealed class LoginOptions - { - /// - /// Default return URL after a successful login flow. - /// If not set, current location will be used. - /// - public string? DefaultReturnUrl { get; set; } +public sealed class LoginOptions +{ + /// + /// Default return URL after a successful login flow. + /// If not set, current location will be used. + /// + public string? DefaultReturnUrl { get; set; } - /// - /// Options related to PKCE-based login flows. - /// - public PkceLoginOptions Pkce { get; set; } = new(); + /// + /// Options related to PKCE-based login flows. + /// + public PkceLoginOptions Pkce { get; set; } = new(); - /// - /// Enables or disables direct credential-based login. - /// - public bool AllowDirectLogin { get; set; } = true; - } + /// + /// Enables or disables direct credential-based login. + /// + public bool AllowDirectLogin { get; set; } = true; +} - public sealed class UAuthClientRefreshOptions - { - /// - /// Enables background refresh coordination. - /// Default: true for BlazorServer, false otherwise. - /// - public bool Enabled { get; set; } = true; +public sealed class UAuthClientRefreshOptions +{ + /// + /// 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; } + /// + /// Interval for background refresh attempts. + /// This is a UX / keep-alive setting, NOT a security policy. + /// + public TimeSpan? Interval { get; set; } - /// - /// Optional jitter to avoid synchronized refresh storms. - /// - public TimeSpan? Jitter { get; set; } - } + /// + /// Optional jitter to avoid synchronized refresh storms. + /// + public TimeSpan? Jitter { get; set; } +} - // TODO: Add ClearCookieOnReauth - public sealed class ReauthOptions - { - public ReauthBehavior Behavior { get; set; } = ReauthBehavior.RedirectToLogin; - public string LoginPath { get; set; } = "/login"; - } +// TODO: Add ClearCookieOnReauth +public sealed class ReauthOptions +{ + public ReauthBehavior Behavior { get; set; } = ReauthBehavior.RedirectToLogin; + public string LoginPath { get; set; } = "/login"; } diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs index 5d3e8a3a..96e06a19 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs @@ -2,30 +2,29 @@ using CodeBeam.UltimateAuth.Core.Runtime; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Client.Options +namespace CodeBeam.UltimateAuth.Client.Options; + +internal sealed class UAuthClientProfileDetector : IClientProfileDetector { - internal sealed class UAuthClientProfileDetector : IClientProfileDetector + public UAuthClientProfile Detect(IServiceProvider sp) { - 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 (sp.GetService() != null) + return UAuthClientProfile.UAuthHub; - if (AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == "Microsoft.AspNetCore.Components.WebAssembly")) - return UAuthClientProfile.BlazorWasm; + if (Type.GetType("Microsoft.Maui.Controls.Application, Microsoft.Maui.Controls", throwOnError: false) is not null) + return UAuthClientProfile.Maui; - // 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; - } + if (AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == "Microsoft.AspNetCore.Components.WebAssembly")) + return UAuthClientProfile.BlazorWasm; - // 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; + // 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/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs index b99dccde..4c2aa91c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs @@ -1,28 +1,27 @@ using CodeBeam.UltimateAuth.Core.Options; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Client.Infrastructure +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class UAuthOptionsPostConfigure : IPostConfigureOptions { - internal sealed class UAuthOptionsPostConfigure : IPostConfigureOptions - { - private readonly IClientProfileDetector _detector; - private readonly IServiceProvider _services; + private readonly IClientProfileDetector _detector; + private readonly IServiceProvider _services; - public UAuthOptionsPostConfigure(IClientProfileDetector detector, IServiceProvider services) - { - _detector = detector; - _services = services; - } + public UAuthOptionsPostConfigure(IClientProfileDetector detector, IServiceProvider services) + { + _detector = detector; + _services = services; + } - public void PostConfigure(string? name, UAuthOptions options) - { - if (!options.AutoDetectClientProfile) - return; + public void PostConfigure(string? name, UAuthOptions options) + { + if (!options.AutoDetectClientProfile) + return; - if (options.ClientProfile != UAuthClientProfile.NotSpecified) - return; + if (options.ClientProfile != UAuthClientProfile.NotSpecified) + return; - options.ClientProfile = _detector.Detect(_services); - } + options.ClientProfile = _detector.Detect(_services); } } diff --git a/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs b/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs index 4ad5ad14..50f66883 100644 --- a/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Runtime; -namespace CodeBeam.UltimateAuth.Client.Runtime +namespace CodeBeam.UltimateAuth.Client.Runtime; + +public sealed class UAuthClientProductInfo { - public sealed class UAuthClientProductInfo - { - public string ProductName { get; init; } = "UltimateAuthClient"; - public UAuthProductInfo Core { get; init; } = default!; - } + public string ProductName { get; init; } = "UltimateAuthClient"; + public UAuthProductInfo Core { get; init; } = default!; } diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs index 222d8eee..448016e8 100644 --- a/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs @@ -1,9 +1,6 @@ -using System.Threading.Tasks; +namespace CodeBeam.UltimateAuth.Client.Runtime; -namespace CodeBeam.UltimateAuth.Client.Runtime +public interface IUAuthClientBootstrapper { - public interface IUAuthClientBootstrapper - { - Task EnsureStartedAsync(); - } + Task EnsureStartedAsync(); } diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs index 0acb423e..e6b12b35 100644 --- a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs @@ -3,51 +3,50 @@ using CodeBeam.UltimateAuth.Client.Infrastructure; // DeviceId is automatically created and managed by UAuthClientProvider. This class is for advanced situations. -namespace CodeBeam.UltimateAuth.Client.Runtime +namespace CodeBeam.UltimateAuth.Client.Runtime; + +internal sealed class UAuthClientBootstrapper : IUAuthClientBootstrapper { - internal sealed class UAuthClientBootstrapper : IUAuthClientBootstrapper - { - private readonly SemaphoreSlim _gate = new(1, 1); - private bool _started; + private readonly SemaphoreSlim _gate = new(1, 1); + private bool _started; - private readonly IDeviceIdProvider _deviceIdProvider; - private readonly IBrowserUAuthBridge _browser; - private readonly ISessionCoordinator _coordinator; + private readonly IDeviceIdProvider _deviceIdProvider; + private readonly IBrowserUAuthBridge _browser; + private readonly ISessionCoordinator _coordinator; - public bool IsStarted => _started; + public bool IsStarted => _started; - public UAuthClientBootstrapper( - IDeviceIdProvider deviceIdProvider, - IBrowserUAuthBridge browser, - ISessionCoordinator coordinator) - { - _deviceIdProvider = deviceIdProvider; - _browser = browser; - _coordinator = coordinator; - } + public UAuthClientBootstrapper( + IDeviceIdProvider deviceIdProvider, + IBrowserUAuthBridge browser, + ISessionCoordinator coordinator) + { + _deviceIdProvider = deviceIdProvider; + _browser = browser; + _coordinator = coordinator; + } - public async Task EnsureStartedAsync() + public async Task EnsureStartedAsync() + { + if (_started) + return; + + await _gate.WaitAsync(); + try { if (_started) return; - await _gate.WaitAsync(); - try - { - if (_started) - return; - - var deviceId = await _deviceIdProvider.GetOrCreateAsync(); - await _browser.SetDeviceIdAsync(deviceId.Value); - await _coordinator.StartAsync(); - - _started = true; - } - finally - { - _gate.Release(); - } - } + var deviceId = await _deviceIdProvider.GetOrCreateAsync(); + await _browser.SetDeviceIdAsync(deviceId.Value); + await _coordinator.StartAsync(); + _started = true; + } + finally + { + _gate.Release(); + } } + } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs deleted file mode 100644 index 1985a95f..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs +++ /dev/null @@ -1,65 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; -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 DefaultAuthorizationClient : IAuthorizationClient - { - private readonly IUAuthRequestClient _request; - private readonly UAuthClientOptions _options; - - public DefaultAuthorizationClient(IUAuthRequestClient request, IOptions options) - { - _request = request; - _options = options.Value; - } - - public async Task> CheckAsync(AuthorizationCheckRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/check"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> GetMyRolesAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/users/me/roles/get"); - var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> GetUserRolesAsync(UserKey userKey) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/get"); - var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson(raw); - } - - public async Task AssignRoleAsync(UserKey userKey, string role) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/post"); - var raw = await _request.SendJsonAsync(url, new AssignRoleRequest - { - Role = role - }); - - return UAuthResultMapper.FromStatus(raw); - } - - public async Task RemoveRoleAsync(UserKey userKey, string role) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/delete"); - - var raw = await _request.SendJsonAsync(url, new AssignRoleRequest - { - Role = role - }); - - return UAuthResultMapper.FromStatus(raw); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs deleted file mode 100644 index 0d00f49b..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs +++ /dev/null @@ -1,103 +0,0 @@ -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 DefaultUserCredentialClient : ICredentialClient - { - private readonly IUAuthRequestClient _request; - private readonly UAuthClientOptions _options; - - public DefaultUserCredentialClient(IUAuthRequestClient request, IOptions options) - { - _request = request; - _options = options.Value; - } - - private string Url(string path) => UAuthUrlBuilder.Combine(_options.Endpoints.Authority, path); - - public async Task> GetMyAsync() - { - var raw = await _request.SendFormForJsonAsync(Url("/credentials/get")); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> AddMyAsync(AddCredentialRequest request) - { - var raw = await _request.SendJsonAsync(Url("/credentials/add"), request); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request) - { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/change"), request); - return UAuthResultMapper.FromJson(raw); - } - - public async Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request) - { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/revoke"), request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request) - { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/begin"), request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request) - { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/complete"), request); - return UAuthResultMapper.FromStatus(raw); - } - - - public async Task> GetUserAsync(UserKey userKey) - { - var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey}/credentials/get")); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) - { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/add"), request); - return UAuthResultMapper.FromJson(raw); - } - - public async Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request) - { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/revoke"), request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task ActivateUserAsync(UserKey userKey, CredentialType type) - { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/activate")); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request) - { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/begin"), request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request) - { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/complete"), request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task DeleteUserAsync(UserKey userKey, CredentialType type) - { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/delete")); - return UAuthResultMapper.FromStatus(raw); - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultFlowClient.cs deleted file mode 100644 index cda6ed49..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/DefaultFlowClient.cs +++ /dev/null @@ -1,221 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Client.Diagnostics; -using CodeBeam.UltimateAuth.Client.Extensions; -using CodeBeam.UltimateAuth.Client.Infrastructure; -using CodeBeam.UltimateAuth.Client.Options; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Options; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace CodeBeam.UltimateAuth.Client.Services -{ - internal class DefaultFlowClient : IFlowClient - { - private readonly IUAuthRequestClient _post; - private readonly UAuthClientOptions _options; - private readonly UAuthOptions _coreOptions; - private readonly UAuthClientDiagnostics _diagnostics; - private readonly NavigationManager _nav; - - public DefaultFlowClient( - IUAuthRequestClient post, - IOptions options, - IOptions coreOptions, - UAuthClientDiagnostics diagnostics, - NavigationManager nav) - { - _post = post; - _options = options.Value; - _coreOptions = coreOptions.Value; - _diagnostics = diagnostics; - _nav = nav; - } - - public async Task LoginAsync(LoginRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Login); - await _post.NavigateAsync(url, request.ToDictionary()); - } - - public async Task LogoutAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Logout); - await _post.NavigateAsync(url); - } - - public async Task RefreshAsync(bool isAuto = false) - { - if (isAuto == false) - { - _diagnostics.MarkManualRefresh(); - } - - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Refresh); - var result = await _post.SendFormAsync(url); - var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); - switch (refreshOutcome) - { - case RefreshOutcome.NoOp: - _diagnostics.MarkRefreshNoOp(); - break; - case RefreshOutcome.Touched: - _diagnostics.MarkRefreshTouched(); - break; - case RefreshOutcome.ReauthRequired: - _diagnostics.MarkRefreshReauthRequired(); - break; - case RefreshOutcome.None: - _diagnostics.MarkRefreshUnknown(); - break; - } - - return new RefreshResult - { - Ok = result.Ok, - Status = result.Status, - Outcome = refreshOutcome - }; - } - - public async Task ReauthAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Reauth); - await _post.NavigateAsync(_options.Endpoints.Reauth); - } - - public async Task ValidateAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate); - var raw = await _post.SendFormForJsonAsync(url); - - if (!raw.Ok || raw.Body is null) - { - return new AuthValidationResult - { - IsValid = false, - State = "transport" - }; - } - - var body = raw.Body.Value.Deserialize( - new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - return body ?? new AuthValidationResult - { - IsValid = false, - State = "deserialize" - }; - } - - public async Task BeginPkceAsync(string? returnUrl = null) - { - var pkce = _options.Login.Pkce; - - if (!pkce.Enabled) - throw new InvalidOperationException("PKCE login is disabled by configuration."); - - var verifier = CreateVerifier(); - var challenge = CreateChallenge(verifier); - - var authorizeUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceAuthorize); - - var raw = await _post.SendFormForJsonAsync( - 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.DefaultReturnUrl - ?? _nav.Uri; - - if (pkce.AutoRedirect) - { - await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl); - } - } - - public async Task CompletePkceLoginAsync(PkceLoginRequest request) - { - if (request is null) - throw new ArgumentNullException(nameof(request)); - - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _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 - }; - - await _post.NavigateAsync(url, payload); - } - - private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) - { - var hubLoginUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.HubLoginPath); - - var data = new Dictionary - { - ["authorization_code"] = authorizationCode, - ["code_verifier"] = codeVerifier, - ["return_url"] = returnUrl, - ["client_profile"] = _coreOptions.ClientProfile.ToString() - }; - - return _post.NavigateAsync(hubLoginUrl, data); - } - - - // ---------------- PKCE CRYPTO ---------------- - - private static string CreateVerifier() - { - var bytes = RandomNumberGenerator.GetBytes(32); - return Base64UrlEncode(bytes); - } - - private static string CreateChallenge(string verifier) - { - using var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); - return Base64UrlEncode(hash); - } - - private static string Base64UrlEncode(byte[] input) - { - return Convert.ToBase64String(input) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs deleted file mode 100644 index 9616a1b7..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs +++ /dev/null @@ -1,77 +0,0 @@ -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 DefaultUserClient : IUserClient - { - private readonly IUAuthRequestClient _request; - private readonly UAuthClientOptions _options; - - public DefaultUserClient(IUAuthRequestClient request, IOptions options) - { - _request = request; - _options = options.Value; - } - - public async Task> GetMeAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/get"); - var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson(raw); - } - - public async Task UpdateMeAsync(UpdateProfileRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/update"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task> CreateAsync(CreateUserRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/create"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/status"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{request.UserKey.Value}/status"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> DeleteAsync(DeleteUserRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/delete"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> GetProfileAsync(UserKey userKey) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/get"); - var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson(raw); - } - - public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/update"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs deleted file mode 100644 index 50a0a6cc..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs +++ /dev/null @@ -1,120 +0,0 @@ -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 -{ - public class DefaultUserIdentifierClient : IUserIdentifierClient - { - private readonly IUAuthRequestClient _request; - private readonly UAuthClientOptions _options; - - public DefaultUserIdentifierClient(IUAuthRequestClient request, IOptions options) - { - _request = request; - _options = options.Value; - } - - public async Task>> GetMyIdentifiersAsync() - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/identifiers/get"); - var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson>(raw); - } - - public async Task AddSelfAsync(AddUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/add"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/update"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/set-primary"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/unset-primary"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/verify"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/delete"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task>> GetUserIdentifiersAsync(UserKey userKey) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey.Value}/identifiers/get"); - var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson>(raw); - } - - public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/add"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/update"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/set-primary"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/unset-primary"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/verify"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - public async Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request) - { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/delete"); - var raw = await _request.SendJsonAsync(url, request); - return UAuthResultMapper.FromStatus(raw); - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs index 75400057..192ef5c0 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs @@ -2,18 +2,17 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Services +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IAuthorizationClient { - public interface IAuthorizationClient - { - Task> CheckAsync(AuthorizationCheckRequest request); + Task> CheckAsync(AuthorizationCheckRequest request); - Task> GetMyRolesAsync(); + Task> GetMyRolesAsync(); - Task> GetUserRolesAsync(UserKey userKey); + Task> GetUserRolesAsync(UserKey userKey); - Task AssignRoleAsync(UserKey userKey, string role); + Task AssignRoleAsync(UserKey userKey, string role); - Task RemoveRoleAsync(UserKey userKey, string role); - } + Task RemoveRoleAsync(UserKey userKey, string role); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs index 468dbcce..eb92db92 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs @@ -2,23 +2,22 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Client.Services +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface ICredentialClient { - public interface ICredentialClient - { - Task> GetMyAsync(); - Task> AddMyAsync(AddCredentialRequest request); - Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request); - Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request); - Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request); - Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request); + Task> GetMyAsync(); + Task> AddMyAsync(AddCredentialRequest request); + Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request); + Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request); + Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request); + Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request); - Task> GetUserAsync(UserKey userKey); - Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); - Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request); - Task ActivateUserAsync(UserKey userKey, CredentialType type); - Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request); - Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request); - Task DeleteUserAsync(UserKey userKey, CredentialType type); - } + Task> GetUserAsync(UserKey userKey); + Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); + Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request); + Task ActivateUserAsync(UserKey userKey, CredentialType type); + Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request); + Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request); + Task DeleteUserAsync(UserKey userKey, CredentialType type); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs index 0a97c461..272959ef 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Client.Services; -namespace CodeBeam.UltimateAuth.Client +namespace CodeBeam.UltimateAuth.Client; + +public interface IUAuthClient { - public interface IUAuthClient - { - IFlowClient Flows { get; } - IUserClient Users { get; } - IUserIdentifierClient Identifiers { get; } - ICredentialClient Credentials { get; } - IAuthorizationClient Authorization { get; } - } + IFlowClient Flows { get; } + IUserClient Users { get; } + IUserIdentifierClient Identifiers { get; } + ICredentialClient Credentials { get; } + IAuthorizationClient Authorization { get; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs index a59a4cdb..88914fff 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs @@ -2,19 +2,18 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Client.Services +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IUserClient { - public interface IUserClient - { - Task> CreateAsync(CreateUserRequest request); - Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); - Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request); - Task> DeleteAsync(DeleteUserRequest request); + Task> CreateAsync(CreateUserRequest request); + Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); + Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request); + Task> DeleteAsync(DeleteUserRequest request); - Task> GetMeAsync(); - Task UpdateMeAsync(UpdateProfileRequest request); + Task> GetMeAsync(); + Task UpdateMeAsync(UpdateProfileRequest request); - Task> GetProfileAsync(UserKey userKey); - Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request); - } + Task> GetProfileAsync(UserKey userKey); + Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs index 5d408165..7f019423 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs @@ -2,24 +2,23 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Client.Services +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IUserIdentifierClient { - public interface IUserIdentifierClient - { - Task>> GetMyIdentifiersAsync(); - Task AddSelfAsync(AddUserIdentifierRequest request); - Task UpdateSelfAsync(UpdateUserIdentifierRequest request); - Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); - Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request); - Task VerifySelfAsync(VerifyUserIdentifierRequest request); - Task DeleteSelfAsync(DeleteUserIdentifierRequest request); + Task>> GetMyIdentifiersAsync(); + Task AddSelfAsync(AddUserIdentifierRequest request); + Task UpdateSelfAsync(UpdateUserIdentifierRequest request); + Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); + Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request); + Task VerifySelfAsync(VerifyUserIdentifierRequest request); + Task DeleteSelfAsync(DeleteUserIdentifierRequest request); - Task>> GetUserIdentifiersAsync(UserKey userKey); - Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); - Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); - Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); - Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request); - Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request); - Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request); - } + Task>> GetUserIdentifiersAsync(UserKey userKey); + Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); + Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); + Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); + Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request); + Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request); + Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs new file mode 100644 index 00000000..d4ed5fa7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -0,0 +1,64 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +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 UAuthClientOptions _options; + + public UAuthAuthorizationClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + public async Task> CheckAsync(AuthorizationCheckRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/check"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetMyRolesAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/users/me/roles/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetUserRolesAsync(UserKey userKey) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task AssignRoleAsync(UserKey userKey, string role) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/post"); + var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + { + Role = role + }); + + return UAuthResultMapper.FromStatus(raw); + } + + public async Task RemoveRoleAsync(UserKey userKey, string role) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/delete"); + + var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + { + Role = role + }); + + return UAuthResultMapper.FromStatus(raw); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs new file mode 100644 index 00000000..55b1fcf4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -0,0 +1,102 @@ +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 UAuthClientOptions _options; + + public UAuthCredentialClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + private string Url(string path) => UAuthUrlBuilder.Combine(_options.Endpoints.Authority, path); + + public async Task> GetMyAsync() + { + var raw = await _request.SendFormForJsonAsync(Url("/credentials/get")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> AddMyAsync(AddCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url("/credentials/add"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/change"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/revoke"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/begin"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/complete"), request); + return UAuthResultMapper.FromStatus(raw); + } + + + public async Task> GetUserAsync(UserKey userKey) + { + var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey}/credentials/get")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/add"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/revoke"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task ActivateUserAsync(UserKey userKey, CredentialType type) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/activate")); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/begin"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/complete"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteUserAsync(UserKey userKey, CredentialType type) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/delete")); + return UAuthResultMapper.FromStatus(raw); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs new file mode 100644 index 00000000..128cd83d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -0,0 +1,212 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +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.Core.Options; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; +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 UAuthClientOptions _options; + private readonly UAuthOptions _coreOptions; + private readonly UAuthClientDiagnostics _diagnostics; + private readonly NavigationManager _nav; + + public UAuthFlowClient( + IUAuthRequestClient post, + IOptions options, + IOptions coreOptions, + UAuthClientDiagnostics diagnostics, + NavigationManager nav) + { + _post = post; + _options = options.Value; + _coreOptions = coreOptions.Value; + _diagnostics = diagnostics; + _nav = nav; + } + + public async Task LoginAsync(LoginRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Login); + await _post.NavigateAsync(url, request.ToDictionary()); + } + + public async Task LogoutAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Logout); + await _post.NavigateAsync(url); + } + + public async Task RefreshAsync(bool isAuto = false) + { + if (isAuto == false) + { + _diagnostics.MarkManualRefresh(); + } + + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Refresh); + var result = await _post.SendFormAsync(url); + var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); + switch (refreshOutcome) + { + case RefreshOutcome.NoOp: + _diagnostics.MarkRefreshNoOp(); + break; + case RefreshOutcome.Touched: + _diagnostics.MarkRefreshTouched(); + break; + case RefreshOutcome.ReauthRequired: + _diagnostics.MarkRefreshReauthRequired(); + break; + case RefreshOutcome.None: + _diagnostics.MarkRefreshUnknown(); + break; + } + + return new RefreshResult + { + Ok = result.Ok, + Status = result.Status, + Outcome = refreshOutcome + }; + } + + public async Task ReauthAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Reauth); + await _post.NavigateAsync(_options.Endpoints.Reauth); + } + + public async Task ValidateAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate); + var raw = await _post.SendFormForJsonAsync(url); + + if (!raw.Ok || raw.Body is null) + { + return new AuthValidationResult + { + IsValid = false, + State = "transport" + }; + } + + var body = raw.Body.Value.Deserialize( + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return body ?? new AuthValidationResult + { + IsValid = false, + State = "deserialize" + }; + } + + public async Task BeginPkceAsync(string? returnUrl = null) + { + var pkce = _options.Login.Pkce; + + if (!pkce.Enabled) + throw new InvalidOperationException("PKCE login is disabled by configuration."); + + var verifier = CreateVerifier(); + var challenge = CreateChallenge(verifier); + + var authorizeUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceAuthorize); + + var raw = await _post.SendFormForJsonAsync( + 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.DefaultReturnUrl + ?? _nav.Uri; + + if (pkce.AutoRedirect) + { + await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl); + } + } + + public async Task CompletePkceLoginAsync(PkceLoginRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _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 + }; + + await _post.NavigateAsync(url, payload); + } + + private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) + { + var hubLoginUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.HubLoginPath); + + var data = new Dictionary + { + ["authorization_code"] = authorizationCode, + ["code_verifier"] = codeVerifier, + ["return_url"] = returnUrl, + ["client_profile"] = _coreOptions.ClientProfile.ToString() + }; + + return _post.NavigateAsync(hubLoginUrl, data); + } + + + // ---------------- PKCE CRYPTO ---------------- + + 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/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs new file mode 100644 index 00000000..e13a3f8b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -0,0 +1,76 @@ +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 UAuthClientOptions _options; + + public UAuthUserClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + public async Task> GetMeAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task UpdateMeAsync(UpdateProfileRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task> CreateAsync(CreateUserRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/create"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/status"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{request.UserKey.Value}/status"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> DeleteAsync(DeleteUserRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/delete"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetProfileAsync(UserKey userKey) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs new file mode 100644 index 00000000..6aeeedca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -0,0 +1,119 @@ +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; + +public class UAuthUserIdentifierClient : IUserIdentifierClient +{ + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public UAuthUserIdentifierClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + public async Task>> GetMyIdentifiersAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/identifiers/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task AddSelfAsync(AddUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/add"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/set-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/unset-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/verify"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/delete"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task>> GetUserIdentifiersAsync(UserKey userKey) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey.Value}/identifiers/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/add"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/set-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/unset-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/verify"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/delete"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + +} 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/Authority/IAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs index bf61d883..a5b3f69c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs @@ -1,10 +1,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface IAccessAuthority - { - AccessDecision Decide(AccessContext context, IEnumerable runtimePolicies); - } +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 index 806d6c91..c043d44d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAccessInvariant { - public interface IAccessInvariant - { - AccessDecision Decide(AccessContext context); - } + AccessDecision Decide(AccessContext context); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs index 487072fe..49a1efad 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAccessPolicy { - public interface IAccessPolicy - { - bool AppliesTo(AccessContext context); - AccessDecision Decide(AccessContext context); - } + 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 index 9a294587..4e5eface 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthAuthority { - public interface IAuthAuthority - { - AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null); - } + 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 index dc0cc0a5..2fe227d1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthorityInvariant { - public interface IAuthorityInvariant - { - AccessDecisionResult Decide(AuthContext context); - } + AccessDecisionResult Decide(AuthContext context); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs index 2b2021a2..5d2bc41d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthorityPolicy { - public interface IAuthorityPolicy - { - bool AppliesTo(AuthContext context); - AccessDecisionResult Decide(AuthContext context); - } + bool AppliesTo(AuthContext context); + AccessDecisionResult Decide(AuthContext context); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs index 36bd1b34..3d5a5817 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IHubCapabilities { - public interface IHubCapabilities - { - bool SupportsPkce { get; } - } + bool SupportsPkce { get; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs index 78ecb59f..f3e075b4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IHubCredentialResolver { - public interface IHubCredentialResolver - { - Task ResolveAsync(HubSessionId hubSessionId, CancellationToken ct = default); - } + 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 index 82764fb4..0096d891 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IHubFlowReader { - public interface IHubFlowReader - { - Task GetStateAsync(HubSessionId hubSessionId, CancellationToken ct = default); - } + 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 index a624091b..71e7a186 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs @@ -1,11 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Provides an abstracted time source for the system. +/// Used to improve testability and ensure consistent time handling. +/// +public interface IClock { - /// - /// Provides an abstracted time source for the system. - /// Used to improve testability and ensure consistent time handling. - /// - public interface IClock - { - DateTimeOffset UtcNow { get; } - } + DateTimeOffset UtcNow { get; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs index 148ddc34..f2fd00f8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; /// /// Contributes seed data for a specific domain (Users, Credentials, Authorization, etc). @@ -12,5 +14,5 @@ public interface ISeedContributor /// int Order { get; } - Task SeedAsync(string? tenantId, CancellationToken ct = default); + 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 index ebf44998..8112e451 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs @@ -1,12 +1,11 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Hashes and verifies sensitive tokens. +/// Used for refresh tokens, session ids, opaque tokens. +/// +public interface ITokenHasher { - /// - /// Hashes and verifies sensitive tokens. - /// Used for refresh tokens, session ids, opaque tokens. - /// - public interface ITokenHasher - { - string Hash(string plaintext); - bool Verify(string plaintext, string hash); - } + string Hash(string plaintext); + bool Verify(string plaintext, string hash); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs index d6596c91..039a8216 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs @@ -1,13 +1,12 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +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 { - /// - /// 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); - } + 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 index 0fe74224..a03e1256 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IJwtTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IJwtTokenGenerator.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Low-level JWT creation abstraction. +/// Can be replaced for asymmetric keys, external KMS, etc. +/// +public interface IJwtTokenGenerator { - /// - /// Low-level JWT creation abstraction. - /// Can be replaced for asymmetric keys, external KMS, etc. - /// - public interface IJwtTokenGenerator - { - string CreateToken(UAuthJwtTokenDescriptor descriptor); - } + string CreateToken(UAuthJwtTokenDescriptor descriptor); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs index 0c49dcfc..a5332d1f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs @@ -1,11 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Generates cryptographically secure random tokens +/// for opaque identifiers, refresh tokens, session ids. +/// +public interface IOpaqueTokenGenerator { - /// - /// Generates cryptographically secure random tokens - /// for opaque identifiers, refresh tokens, session ids. - /// - public interface IOpaqueTokenGenerator - { - string Generate(int byteLength = 32); - } + string Generate(int byteLength = 32); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index 39ec7c59..3e8bca20 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -1,20 +1,20 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ISessionIssuer { - public interface ISessionIssuer - { - Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); - Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); + Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default); + Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default); - Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default); + Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default); - Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); + Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); - Task RevokeRootAsync(string? tenantId, UserKey userKey, 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 index 3be15c91..b51990e9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs @@ -1,8 +1,9 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core; public interface IUserClaimsProvider { - Task GetClaimsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + 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 index 288e05bb..1a222d83 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs @@ -1,49 +1,48 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +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 { /// - /// 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. + /// Converts the typed user identifier into its canonical string representation. /// - 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 ToString(TUserId id); + /// 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); + /// + /// 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 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); - } + /// + /// 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 index fc642d37..bd8dd807 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs @@ -1,23 +1,22 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +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 { /// - /// 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. + /// Retrieves the registered for the specified user ID type. /// - 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); - } + /// 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 index b5d2715d..c72f650f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs @@ -1,16 +1,15 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +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 { /// - /// Responsible for creating new user identifiers. - /// This abstraction allows UltimateAuth to remain - /// independent from the concrete user ID type. + /// Creates a new unique user identifier. /// - /// User identifier type. - public interface IUserIdFactory - { - /// - /// Creates a new unique user identifier. - /// - TUserId Create(); - } + TUserId Create(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs deleted file mode 100644 index 9dc9df9f..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface ISessionService - { - Task RevokeAllAsync(AuthContext authContext, UserKey userKey, CancellationToken ct = default); - Task RevokeAllExceptChainAsync(AuthContext authContext, UserKey userKey, SessionChainId exceptChainId, CancellationToken ct = default); - Task RevokeRootAsync(AuthContext authContext, UserKey userKey, CancellationToken ct = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs deleted file mode 100644 index 9486398d..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// High-level facade for UltimateAuth. - /// Provides access to authentication flows, - /// session lifecycle and user operations. - /// - //public interface IUAuthService - //{ - // //IUAuthFlowService Flow { get; } - // IUAuthSessionManager Sessions { get; } - // //IUAuthTokenService Tokens { get; } - // IUAuthUserService Users { get; } - //} -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs index 2f759e92..996b5a64 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs @@ -1,27 +1,31 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Application-level session command API. +/// Represents explicit intent to mutate session state. +/// All operations are authorization- and policy-aware. +/// +public interface IUAuthSessionManager { /// - /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation. + /// Revokes a single session (logout current device). /// - public interface IUAuthSessionManager - { - Task> GetChainsAsync(string? tenantId, UserKey userKey); - - Task> GetSessionsAsync(string? tenantId, SessionChainId chainId); - - Task GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId); + Task RevokeSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at); - - Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at); + /// + /// Revokes all sessions in a specific chain (logout a device). + /// + Task RevokeChainAsync(SessionChainId chainId, CancellationToken ct = default); - Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId); + /// + /// Revokes all session chains for the current user (logout all devices). + /// + Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId = null, CancellationToken ct = default); - Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at); - - // Hard revoke - admin - Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at); - } + /// + /// Hard revoke: revokes the entire session root (admin / security action). + /// + Task RevokeRootAsync(UserKey userKey, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs deleted file mode 100644 index d546e55f..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs +++ /dev/null @@ -1,15 +0,0 @@ -//using CodeBeam.UltimateAuth.Core.Contracts; - -//namespace CodeBeam.UltimateAuth.Core.Abstractions -//{ -// /// -// /// Defines the minimal user authentication contract expected by UltimateAuth. -// /// This service does not manage sessions, tokens, or transport concerns. -// /// For user management, CodeBeam.UltimateAuth.Users package is recommended. -// /// -// public interface IUAuthUserService -// { -// Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); -// Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); -// } -//} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs deleted file mode 100644 index 25fac768..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/DefaultSessionStoreFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Default session store factory that throws until a real store implementation is registered. - /// - internal sealed class DefaultSessionStoreFactory : ISessionStoreKernelFactory - { - private readonly IServiceProvider _sp; - - public DefaultSessionStoreFactory(IServiceProvider sp) - { - _sp = sp; - } - - public ISessionStoreKernel Create(string? tenantId) - => _sp.GetRequiredService(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs index edf2d58e..7006f2d1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs @@ -1,15 +1,16 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +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 { - /// - /// Optional persistence for access token identifiers (jti). - /// Used for revocation and replay protection. - /// - public interface IAccessTokenIdStore - { - Task StoreAsync(string? tenantId, string jti, DateTimeOffset expiresAt, CancellationToken ct = default); - - Task IsRevokedAsync(string? tenantId, string jti, CancellationToken ct = default); - - Task RevokeAsync(string? tenantId, string jti, DateTimeOffset revokedAt, CancellationToken ct = default); - } + 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 index eb6e52c3..cf43b46a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Abstractions; @@ -8,15 +9,15 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; /// public interface IRefreshTokenStore { - Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default); + Task StoreAsync(TenantKey tenant, StoredRefreshToken token, CancellationToken ct = default); - Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default); + Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default); - Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); + Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); - Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); - Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); - Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default); + Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs deleted file mode 100644 index cca6ea0a..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ /dev/null @@ -1,46 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// High-level session store abstraction used by UltimateAuth. - /// Encapsulates session, chain, and root orchestration. - /// - public interface ISessionStore - { - /// - /// Retrieves an active session by id. - /// - Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); - - /// - /// Creates a new session and associates it with the appropriate chain and root. - /// - Task CreateSessionAsync(IssuedSession issuedSession, SessionStoreContext context, CancellationToken ct = default); - - /// - /// Refreshes (rotates) the active session within its chain. - /// - Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession newSession, SessionStoreContext context, CancellationToken ct = default); - - Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default); - - /// - /// Revokes a single session. - /// - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default); - - /// - /// Revokes all sessions for a specific user (all devices). - /// - Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); - - /// - /// Revokes all sessions within a specific chain (single device). - /// - Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); - - Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs index 578f2427..05148ed1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs @@ -1,36 +1,28 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface ISessionStoreKernel - { - Task ExecuteAsync(Func action, CancellationToken ct = default); - //string? TenantId { get; } +namespace CodeBeam.UltimateAuth.Core.Abstractions; - // Session - Task GetSessionAsync(AuthSessionId sessionId); - Task SaveSessionAsync(ISession session); - Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); +public interface ISessionStoreKernel +{ + Task ExecuteAsync(Func action, CancellationToken ct = default); - // Chain - Task GetChainAsync(SessionChainId chainId); - Task SaveChainAsync(ISessionChain chain); - Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at); - Task GetActiveSessionIdAsync(SessionChainId chainId); - Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId); + Task GetSessionAsync(AuthSessionId sessionId); + Task SaveSessionAsync(UAuthSession session); + Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); - // Root - Task GetSessionRootByUserAsync(UserKey userKey); - Task GetSessionRootByIdAsync(SessionRootId rootId); - Task SaveSessionRootAsync(ISessionRoot root); - Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at); + Task GetChainAsync(SessionChainId chainId); + Task SaveChainAsync(UAuthSessionChain chain); + Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at); + Task GetActiveSessionIdAsync(SessionChainId chainId); + Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId); - // Helpers - Task GetChainIdBySessionAsync(AuthSessionId sessionId); - Task> GetChainsByUserAsync(UserKey userKey); - Task> GetSessionsByChainAsync(SessionChainId chainId); + Task GetSessionRootByUserAsync(UserKey userKey); + Task GetSessionRootByIdAsync(SessionRootId rootId); + Task SaveSessionRootAsync(UAuthSessionRoot root); + Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at); - // Maintenance - Task DeleteExpiredSessionsAsync(DateTimeOffset at); - } + Task GetChainIdBySessionAsync(AuthSessionId sessionId); + Task> GetChainsByUserAsync(UserKey userKey); + Task> GetSessionsByChainAsync(SessionChainId chainId); + Task DeleteExpiredSessionsAsync(DateTimeOffset at); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs index b529fa62..936731e2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs @@ -1,21 +1,19 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +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 ISessionStoreKernelFactory { /// - /// 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. + /// Creates and returns a session store instance for the specified user ID type within the given tenant context. /// - public interface ISessionStoreKernelFactory - { - /// - /// Creates and returns a session store instance for the specified user ID type within the given tenant context. - /// - /// - /// The tenant identifier for multi-tenant environments, or null for single-tenant mode. - /// - /// - /// An implementation able to perform session persistence operations. - /// - ISessionStoreKernel Create(string? tenantId); - } + /// + /// An implementation able to perform session persistence operations. + /// + ISessionStoreKernel Create(TenantKey tenant); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs deleted file mode 100644 index 2a90b92c..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITenantAwareSessionStore.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface ITenantAwareSessionStore - { - void BindTenant(string? tenantId); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs deleted file mode 100644 index b97c47e1..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs +++ /dev/null @@ -1,52 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Provides minimal user lookup and security metadata required for authentication. - /// This store does not manage user creation, claims, or profile data — these belong - /// to higher-level application services outside UltimateAuth. - /// - public interface IUAuthUserStore - { - Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken token = default); - - Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default); - - /// - /// Retrieves a user by a login credential such as username or email. - /// Returns null if no matching user exists. - /// - /// The user instance or null if not found. - Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken token = default); - - /// - /// Returns the password hash for the specified user, if the user participates - /// in password-based authentication. Returns null for passwordless users - /// (e.g., external login or passkey-only accounts). - /// - /// The password hash or null. - Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken token = default); - - /// - /// Updates the password hash for the specified user. This method is invoked by - /// password management services and not by . - /// - Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default); - - /// - /// Retrieves the security version associated with the user. - /// This value increments whenever critical security actions occur, such as: - /// password reset, MFA reset, external login removal, or account recovery. - /// - /// The current security version. - Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default); - - /// - /// Increments the user's security version, invalidating all existing sessions. - /// This is typically called after sensitive security events occur. - /// - Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs deleted file mode 100644 index 23f85519..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Provides a factory abstraction for creating tenant-scoped user store - /// instances used for retrieving basic user information required by - /// UltimateAuth authentication services. - /// - public interface IUserStoreFactory - { - /// - /// Creates and returns a user store instance for the specified user ID type within the given tenant context. - /// - /// The type used to uniquely identify users. - /// - /// The tenant identifier for multi-tenant environments, or null - /// in single-tenant deployments. - /// - /// - /// An implementation capable of user lookup and security metadata retrieval. - /// - /// - /// Thrown if no user store implementation has been registered for the given user ID type. - /// - IUAuthUserStore Create(string tenantId); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs index ae7defd3..1d335fc3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Abstractions; @@ -8,5 +9,5 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; /// public interface IUserRuntimeStateProvider { - Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + 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 index f342e7c6..404422a8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Validates access tokens (JWT or opaque) and resolves +/// the authenticated user context. +/// +public interface IJwtValidator { - /// - /// Validates access tokens (JWT or opaque) and resolves - /// the authenticated user context. - /// - public interface IJwtValidator - { - Task> ValidateAsync(string token, CancellationToken ct = default); - } + Task> ValidateAsync(string token, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index 50348cae..768f9e27 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index 238390b5..ca4b8406 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -1,55 +1,55 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class AccessContext { - 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; } + + // Target + public string? Resource { get; init; } + public string? ResourceId { 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 && ResourceId != null && string.Equals(ActorUserKey.Value, ResourceId, StringComparison.Ordinal); + public bool HasActor => ActorUserKey != null; + public bool HasTarget => ResourceId != null; + + public UserKey GetTargetUserKey() { - // Actor - public UserKey? ActorUserKey { get; init; } - public string? ActorTenantId { get; init; } - public bool IsAuthenticated { get; init; } - public bool IsSystemActor { get; init; } - - // Target - public string? Resource { get; init; } - public string? ResourceId { get; init; } - public string? ResourceTenantId { get; init; } - - public string Action { get; init; } = default!; - public IReadOnlyDictionary Attributes { get; init; } = EmptyAttributes.Instance; - - public bool IsCrossTenant => ActorTenantId != null && ResourceTenantId != null && !string.Equals(ActorTenantId, ResourceTenantId, StringComparison.Ordinal); - public bool IsSelfAction => ActorUserKey != null && ResourceId != null && string.Equals(ActorUserKey.Value, ResourceId, StringComparison.Ordinal); - public bool HasActor => ActorUserKey != null; - public bool HasTarget => ResourceId != null; - - public UserKey GetTargetUserKey() - { - if (ResourceId is null) - throw new InvalidOperationException("Target user is not specified."); - - return UserKey.Parse(ResourceId, null); - } + if (ResourceId is null) + throw new InvalidOperationException("Target user is not specified."); + + return UserKey.Parse(ResourceId, null); } +} + +internal sealed class EmptyAttributes : IReadOnlyDictionary +{ + public static readonly EmptyAttributes Instance = new(); + + private EmptyAttributes() { } - internal sealed class EmptyAttributes : IReadOnlyDictionary + 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) { - 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(); + 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 index 2320615a..fa89076e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs @@ -1,40 +1,36 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AccessDecision { - public sealed record AccessDecision - { - public bool IsAllowed { get; } - public bool RequiresReauthentication { get; } - public string? DenyReason { get; } + 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; - } + 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 Allow() + => new( + isAllowed: true, + requiresReauthentication: false, + denyReason: null); - public static AccessDecision Deny(string reason) - => new( - isAllowed: false, - requiresReauthentication: false, - denyReason: reason); + 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 static AccessDecision ReauthenticationRequired(string? reason = null) + => new( + isAllowed: false, + requiresReauthentication: true, + denyReason: reason); - public bool IsDenied => - !IsAllowed && !RequiresReauthentication; - } + 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 index e157c940..a1e2002b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs @@ -1,29 +1,26 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed class AccessDecisionResult - { - public AuthorizationDecision Decision { get; } - public string? Reason { get; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - private AccessDecisionResult(AuthorizationDecision decision, string? reason) - { - Decision = decision; - Reason = reason; - } +public sealed class AccessDecisionResult +{ + public AuthorizationDecision Decision { get; } + public string? Reason { get; } - public static AccessDecisionResult Allow() - => new(AuthorizationDecision.Allow, null); + private AccessDecisionResult(AuthorizationDecision decision, string? reason) + { + Decision = decision; + Reason = reason; + } - public static AccessDecisionResult Deny(string reason) - => new(AuthorizationDecision.Deny, reason); + public static AccessDecisionResult Allow() + => new(AuthorizationDecision.Allow, null); - public static AccessDecisionResult Challenge(string reason) - => new(AuthorizationDecision.Challenge, reason); + public static AccessDecisionResult Deny(string reason) + => new(AuthorizationDecision.Deny, reason); - // Developer happiness helpers - public bool IsAllowed => Decision == AuthorizationDecision.Allow; - public bool IsDenied => Decision == AuthorizationDecision.Deny; - public bool RequiresChallenge => Decision == AuthorizationDecision.Challenge; - } + 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 index d31c6e24..65eeadaf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -1,19 +1,19 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AuthContext { - public sealed record AuthContext - { - public string? TenantId { get; init; } + public TenantKey Tenant { get; init; } - public AuthOperation Operation { get; init; } + public AuthOperation Operation { get; init; } - public UAuthMode Mode { get; init; } + public UAuthMode Mode { get; init; } - public SessionSecurityContext? Session { get; init; } + public SessionSecurityContext? Session { get; init; } - public required DeviceContext Device { get; init; } + public required DeviceContext Device { get; init; } - public DateTimeOffset At { 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 index 8f41f0d7..9f886535 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs @@ -1,12 +1,11 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum AuthOperation { - public enum AuthOperation - { - Login, - Access, - Refresh, - Revoke, - Logout, - System - } + Login, + Access, + Refresh, + Revoke, + Logout, + System } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs index 80d71022..5f329623 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs @@ -1,10 +1,8 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public enum AuthorizationDecision - { - Allow, - Deny, - Challenge - } +namespace CodeBeam.UltimateAuth.Core.Contracts; +public enum AuthorizationDecision +{ + Allow, + Deny, + Challenge } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs index a4cd82ad..46d8241a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs @@ -1,10 +1,8 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public enum DeviceMismatchBehavior - { - Reject, // 401 - Allow, // Accept session - AllowAndRebind // Accept and update device info - } +namespace CodeBeam.UltimateAuth.Core.Contracts; +public enum DeviceMismatchBehavior +{ + Reject, + Allow, + AllowAndRebind } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs index e28fa7b8..5063bda6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum DeleteMode { - public enum DeleteMode - { - Soft, - Hard - } + Soft, + Hard } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs index 404b62b8..e3b92ad3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -1,14 +1,13 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class PagedResult { - public sealed class PagedResult - { - public IReadOnlyList Items { get; } - public int TotalCount { get; } + public IReadOnlyList Items { get; } + public int TotalCount { get; } - public PagedResult(IReadOnlyList items, int totalCount) - { - Items = items; - TotalCount = totalCount; - } + public PagedResult(IReadOnlyList items, int totalCount) + { + Items = items; + TotalCount = totalCount; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index 2437c850..31f83adb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -1,20 +1,18 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public class UAuthResult - { - public bool Ok { get; init; } - public int Status { get; init; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - public string? Error { get; init; } - public string? ErrorCode { get; init; } +public class UAuthResult +{ + public bool Ok { get; init; } + public int Status { get; init; } - public bool IsUnauthorized => Status == 401; - public bool IsForbidden => Status == 403; - } + public string? Error { get; init; } + public string? ErrorCode { get; init; } - public sealed class UAuthResult : UAuthResult - { - public T? Value { get; init; } - } + public bool IsUnauthorized => Status == 401; + public bool IsForbidden => Status == 403; +} +public sealed class UAuthResult : UAuthResult +{ + public T? Value { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs index 6126e7d7..9a9555bd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs @@ -1,11 +1,11 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record ExternalLoginRequest - { - public string? TenantId { get; init; } - public string Provider { get; init; } = default!; - public string ExternalToken { get; init; } = default!; - public string? DeviceId { get; init; } - } +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; +public sealed record ExternalLoginRequest +{ + public TenantKey Tenant { get; init; } + public string Provider { get; init; } = default!; + public string ExternalToken { get; init; } = default!; + public string? DeviceId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs index ec5fb02b..7d39fc7d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs @@ -1,20 +1,19 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record LoginContinuation { - public sealed record LoginContinuation - { - /// - /// Gets the type of login continuation required. - /// - public LoginContinuationType Type { get; init; } + /// + /// 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!; + /// + /// 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; } - } + /// + /// 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 index 662fbef9..d8d953d3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum LoginContinuationType { - public enum LoginContinuationType - { - Mfa, - Pkce, - External - } + Mfa, + Pkce, + External } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index 3ff02bd0..23769bec 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -1,23 +1,23 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record LoginRequest { - public sealed record LoginRequest - { - public string? TenantId { get; init; } - public string Identifier { get; init; } = default!; // username, email etc. - public string Secret { get; init; } = default!; // password - public DateTimeOffset? At { get; init; } - public required DeviceContext Device { get; init; } - public IReadOnlyDictionary? Metadata { get; init; } + public TenantKey Tenant { get; init; } + public string Identifier { get; init; } = default!; + public string Secret { get; init; } = default!; + public DateTimeOffset? At { get; init; } + public required DeviceContext Device { get; init; } + 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; + /// + /// Hint to request access/refresh tokens when the server mode supports it. + /// Server policy may still ignore this. + /// + public bool RequestTokens { get; init; } = true; - // Optional - public SessionChainId? ChainId { get; init; } - } + // Optional + public SessionChainId? ChainId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs index 8324739e..31548156 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -1,44 +1,41 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record LoginResult { - public sealed record LoginResult - { - public LoginStatus Status { get; init; } - public AuthSessionId? SessionId { get; init; } - public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } - public LoginContinuation? Continuation { get; init; } - public AuthFailureReason? FailureReason { get; init; } + public LoginStatus Status { get; init; } + public AuthSessionId? SessionId { get; init; } + public AccessToken? AccessToken { get; init; } + public RefreshToken? RefreshToken { get; init; } + public LoginContinuation? Continuation { get; init; } + public AuthFailureReason? FailureReason { get; init; } - // Helpers - 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 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) - => new() - { - Status = LoginStatus.Failed, - FailureReason = reason - }; + public static LoginResult Failed(AuthFailureReason? reason = null) + => new() + { + Status = LoginStatus.Failed, + FailureReason = reason + }; - public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null) - => new() - { - Status = LoginStatus.Success, - SessionId = sessionId, - AccessToken = tokens?.AccessToken, - RefreshToken = tokens?.RefreshToken - }; + public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null) + => new() + { + Status = LoginStatus.Success, + SessionId = sessionId, + AccessToken = tokens?.AccessToken, + RefreshToken = tokens?.RefreshToken + }; - public static LoginResult Continue(LoginContinuation continuation) - => new() - { - Status = LoginStatus.RequiresContinuation, - Continuation = continuation - }; - } + 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 index 94a3902c..95a03a12 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum LoginStatus { - public enum LoginStatus - { - Success, - RequiresContinuation, - Failed - } + Success, + RequiresContinuation, + Failed } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs index b1d25650..5717252f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record ReauthRequest { - public sealed record ReauthRequest - { - public string? TenantId { get; init; } - public AuthSessionId SessionId { get; init; } - public string Secret { get; init; } = default!; - } + public TenantKey Tenant { get; init; } + public AuthSessionId SessionId { get; init; } + public string Secret { get; init; } = default!; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs index d14eb108..a047ff1d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record ReauthResult { - public sealed record ReauthResult - { - public bool Success { get; init; } - } + public bool Success { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs index 4263a08f..2395ccb4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum UAuthLoginType { - public enum UAuthLoginType - { - Password, // /auth/login - Pkce // /auth/pkce/complete - } + Password, // /auth/login + Pkce // /auth/pkce/complete } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs index 2aa6b6af..5cc76251 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs @@ -1,23 +1,22 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed class LogoutAllRequest - { - public string? TenantId { get; init; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - /// - /// The current session initiating the logout-all operation. - /// Used to resolve the active chain when ExceptCurrent is true. - /// - public AuthSessionId? CurrentSessionId { get; init; } +public sealed class LogoutAllRequest +{ + public TenantKey Tenant { get; init; } - /// - /// If true, the current session will NOT be revoked. - /// - public bool ExceptCurrent { get; init; } + /// + /// The current session initiating the logout-all operation. + /// Used to resolve the active chain when ExceptCurrent is true. + /// + public AuthSessionId? CurrentSessionId { get; init; } - public DateTimeOffset? At { get; init; } - } + /// + /// If true, the current session will NOT be revoked. + /// + public bool ExceptCurrent { get; init; } + public DateTimeOffset? At { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs index 7229f0ad..050a9b9d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs @@ -1,12 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record LogoutRequest - { - public string? TenantId { get; init; } - public AuthSessionId SessionId { get; init; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - public DateTimeOffset? At { get; init; } - } +public sealed record LogoutRequest +{ + public TenantKey Tenant { get; init; } + public AuthSessionId SessionId { get; init; } + public DateTimeOffset? At { 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 index 86af91a4..38f945b1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record BeginMfaRequest { - public sealed record BeginMfaRequest - { - public string MfaToken { get; init; } = default!; - } + public string MfaToken { get; init; } = default!; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs index 5d575d0b..abf719ff 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record CompleteMfaRequest { - public sealed record CompleteMfaRequest - { - public string ChallengeId { get; init; } = default!; - public string Code { get; init; } = default!; - } + public string ChallengeId { get; init; } = default!; + public string Code { get; init; } = default!; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs index 9bb085c8..f12ccedd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record MfaChallengeResult { - public sealed record MfaChallengeResult - { - public string ChallengeId { get; init; } = default!; - public string Method { get; init; } = default!; // totp, sms, email etc. - } + public string ChallengeId { get; init; } = default!; + public string Method { get; init; } = default!; // totp, sms, email etc. } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs index 12a10364..d04b6eca 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs @@ -1,11 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +internal sealed class PkceCompleteRequest { - internal sealed class PkceCompleteRequest - { - public string AuthorizationCode { get; init; } = default!; - public string CodeVerifier { get; init; } = default!; - public string Identifier { get; init; } = default!; - public string Secret { get; init; } = default!; - public string ReturnUrl { get; init; } = default!; - } + public string AuthorizationCode { get; init; } = default!; + public string CodeVerifier { get; init; } = default!; + public string Identifier { get; init; } = default!; + public string Secret { get; init; } = default!; + public string ReturnUrl { get; init; } = default!; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs index 6c0a2f89..9d446030 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed class PkceLoginRequest { @@ -9,5 +11,5 @@ public sealed class PkceLoginRequest public string Identifier { get; init; } = default!; public string Secret { get; init; } = default!; - public string? TenantId { get; init; } + public TenantKey Tenant { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs index 21b180eb..a6120541 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class RefreshFlowRequest { - public sealed class RefreshFlowRequest - { - public AuthSessionId? SessionId { get; init; } - public string? RefreshToken { get; init; } - public required DeviceContext Device { get; init; } - public DateTimeOffset Now { get; init; } - public SessionTouchMode TouchMode { get; init; } = SessionTouchMode.IfNeeded; - } + public AuthSessionId? SessionId { get; init; } + public string? RefreshToken { get; init; } + public required DeviceContext Device { get; init; } + public DateTimeOffset Now { 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 index 7c1f26eb..51b81396 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs @@ -1,40 +1,39 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class RefreshFlowResult { - public sealed class RefreshFlowResult - { - public bool Succeeded { get; init; } - public RefreshOutcome Outcome { get; init; } + public bool Succeeded { get; init; } + public RefreshOutcome Outcome { get; init; } - public AuthSessionId? SessionId { get; init; } - public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public AuthSessionId? SessionId { get; init; } + public AccessToken? AccessToken { get; init; } + public RefreshToken? RefreshToken { get; init; } - public static RefreshFlowResult ReauthRequired() + public static RefreshFlowResult ReauthRequired() + { + return new RefreshFlowResult { - return new RefreshFlowResult - { - Succeeded = false, - Outcome = RefreshOutcome.ReauthRequired - }; - } + Succeeded = false, + Outcome = RefreshOutcome.ReauthRequired + }; + } - public static RefreshFlowResult Success( - RefreshOutcome outcome, - AuthSessionId? sessionId = null, - AccessToken? accessToken = null, - RefreshToken? refreshToken = null) + public static RefreshFlowResult Success( + RefreshOutcome outcome, + AuthSessionId? sessionId = null, + AccessToken? accessToken = null, + RefreshToken? refreshToken = null) + { + return new RefreshFlowResult { - return new RefreshFlowResult - { - Succeeded = true, - Outcome = outcome, - SessionId = sessionId, - AccessToken = accessToken, - RefreshToken = refreshToken - }; - } - + 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 index e4352d05..3c22c330 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs @@ -1,11 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum RefreshStrategy { - public enum RefreshStrategy - { - NotSupported, - SessionOnly, // PureOpaque - TokenOnly, // PureJwt - TokenWithSessionCheck, // SemiHybrid - SessionAndToken // Hybrid - } + NotSupported, + SessionOnly, // PureOpaque + TokenOnly, // PureJwt + TokenWithSessionCheck, // SemiHybrid + SessionAndToken // Hybrid } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs index dc5891cf..a9d308d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs @@ -1,18 +1,17 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum RefreshTokenPersistence { - public enum RefreshTokenPersistence - { - /// - /// Refresh token store'a yazılır. - /// Login, first-issue gibi normal akışlar için. - /// - Persist, + /// + /// Refresh token store'a yazılır. + /// Login, first-issue gibi normal akışlar için. + /// + Persist, - /// - /// Refresh token store'a yazılmaz. - /// Rotation gibi özel akışlarda, - /// caller tarafından kontrol edilir. - /// - DoNotPersist - } + /// + /// Refresh token store'a yazılmaz. + /// Rotation gibi özel akışlarda, + /// caller tarafından kontrol edilir. + /// + DoNotPersist } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs index 6b9375de..01d25a1e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs @@ -1,15 +1,14 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshTokenValidationContext { - public sealed record RefreshTokenValidationContext - { - public string? TenantId { get; init; } - public string RefreshToken { get; init; } = default!; - public DateTimeOffset Now { get; init; } + public TenantKey Tenant { get; init; } + public string RefreshToken { get; init; } = default!; + public DateTimeOffset Now { get; init; } - // For Hybrid & Advanced - public required DeviceContext Device { get; init; } - public AuthSessionId? ExpectedSessionId { get; init; } - } + public required DeviceContext Device { get; init; } + public AuthSessionId? ExpectedSessionId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs index 704398a6..c5bff8da 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs @@ -1,16 +1,14 @@ using CodeBeam.UltimateAuth.Core.Domain; -using System.Security.Claims; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AuthStateSnapshot { - public sealed record AuthStateSnapshot - { - // It's not UserId type - public string? UserId { get; init; } - public string? TenantId { get; init; } + public UserKey UserKey { get; init; } + public TenantKey Tenant { get; init; } - public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; - public DateTimeOffset? AuthenticatedAt { get; init; } - } + public DateTimeOffset? AuthenticatedAt { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs index 1cf3e36b..0baf518b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs @@ -1,26 +1,25 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AuthValidationResult { - public sealed record AuthValidationResult - { - public bool IsValid { get; init; } - public string? State { get; init; } - public int? RemainingAttempts { get; init; } + public bool IsValid { get; init; } + public string? State { get; init; } + public int? RemainingAttempts { get; init; } - public AuthStateSnapshot? Snapshot { get; init; } + public AuthStateSnapshot? Snapshot { get; init; } - public static AuthValidationResult Valid(AuthStateSnapshot? snapshot = null) + public static AuthValidationResult Valid(AuthStateSnapshot? snapshot = null) + => new() + { + IsValid = true, + State = "active", + Snapshot = snapshot + }; + + public static AuthValidationResult Invalid(string state) => new() { - IsValid = true, - State = "active", - Snapshot = snapshot + IsValid = false, + State = state }; - - public static AuthValidationResult Invalid(string state) - => new() - { - IsValid = false, - State = state - }; - } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs index 08890b7a..4a83eb93 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs @@ -1,31 +1,31 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents the context in which a session is issued +/// (login, refresh, reauthentication). +/// +public sealed class AuthenticatedSessionContext { + 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; } + /// - /// Represents the context in which a session is issued - /// (login, refresh, reauthentication). + /// Optional chain identifier. + /// If null, a new chain will be created. + /// If provided, session will be issued under the existing chain. /// - public sealed class AuthenticatedSessionContext - { - public string? TenantId { 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; } - - /// - /// 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; } + 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; - } + /// + /// 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/IssuedSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs index 0d1622de..6a76788b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs @@ -1,27 +1,25 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents the result of a session issuance operation. +/// +public sealed class IssuedSession { /// - /// Represents the result of a session issuance operation. + /// The issued domain session. /// - public sealed class IssuedSession - { - /// - /// The issued domain session. - /// - public required ISession Session { get; init; } - - /// - /// Opaque session identifier returned to the client. - /// - public required string OpaqueSessionId { get; init; } + public required UAuthSession Session { get; init; } - /// - /// Indicates whether this issuance is metadata-only - /// (used in SemiHybrid mode). - /// - public bool IsMetadataOnly { 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 index ece8d802..42c51971 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs @@ -1,38 +1,35 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record ResolvedRefreshSession { - public sealed record ResolvedRefreshSession - { - public bool IsValid { get; init; } - public bool IsReuseDetected { get; init; } + public bool IsValid { get; init; } + public bool IsReuseDetected { get; init; } - public ISession? Session { get; init; } - public ISessionChain? Chain { get; init; } + public UAuthSession? Session { get; init; } + public UAuthSessionChain? Chain { get; init; } - private ResolvedRefreshSession() { } + private ResolvedRefreshSession() { } - public static ResolvedRefreshSession Invalid() - => new() - { - IsValid = false - }; + public static ResolvedRefreshSession Invalid() + => new() + { + IsValid = false + }; - public static ResolvedRefreshSession Reused() - => new() - { - IsValid = false, - IsReuseDetected = true - }; + public static ResolvedRefreshSession Reused() + => new() + { + IsValid = false, + IsReuseDetected = true + }; - public static ResolvedRefreshSession Valid( - ISession session, - ISessionChain chain) - => new() - { - IsValid = true, - Session = session, - Chain = chain - }; - } + 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 index 93d5aba9..0426b18a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs @@ -1,29 +1,27 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +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 { - /// - /// 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 string? TenantId { get; } + public AuthSessionId? SessionId { get; } + public TenantKey? Tenant { get; } - public bool IsAnonymous => SessionId is null; + public bool IsAnonymous => SessionId is null; - private SessionContext(AuthSessionId? sessionId, string? tenantId) - { - SessionId = sessionId; - TenantId = tenantId; - } + private SessionContext(AuthSessionId? sessionId, TenantKey? tenant) + { + SessionId = sessionId; + Tenant = tenant; + } - public static SessionContext Anonymous() - => new(null, null); + public static SessionContext Anonymous() => new(null, null); - public static SessionContext FromSessionId(AuthSessionId sessionId, string? tenantId) - => new(sessionId, tenantId); - } + public static SessionContext FromSessionId(AuthSessionId sessionId, TenantKey tenant) => new(sessionId, tenant); } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs index 9343883f..d15f1cfc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs @@ -1,8 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionRefreshRequest { - public sealed record SessionRefreshRequest - { - public string? TenantId { get; init; } - public string RefreshToken { get; init; } = default!; - } + public TenantKey Tenant { get; init; } + public string RefreshToken { get; init; } = default!; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs index d06a8542..9d5c578b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -1,47 +1,46 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record SessionRefreshResult - { - public SessionRefreshStatus Status { get; init; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - public AuthSessionId? SessionId { get; init; } +public sealed record SessionRefreshResult +{ + public SessionRefreshStatus Status { get; init; } - public bool DidTouch { get; init; } + public AuthSessionId? SessionId { get; init; } - public bool IsSuccess => Status == SessionRefreshStatus.Success; - public bool RequiresReauth => Status == SessionRefreshStatus.ReauthRequired; + public bool DidTouch { get; init; } - private SessionRefreshResult() { } + public bool IsSuccess => Status == SessionRefreshStatus.Success; + public bool RequiresReauth => Status == SessionRefreshStatus.ReauthRequired; - public static SessionRefreshResult Success( - AuthSessionId sessionId, - bool didTouch = false) - => new() - { - Status = SessionRefreshStatus.Success, - SessionId = sessionId, - DidTouch = didTouch - }; + private SessionRefreshResult() { } - public static SessionRefreshResult ReauthRequired() + public static SessionRefreshResult Success( + AuthSessionId sessionId, + bool didTouch = false) => new() { - Status = SessionRefreshStatus.ReauthRequired + Status = SessionRefreshStatus.Success, + SessionId = sessionId, + DidTouch = didTouch }; - public static SessionRefreshResult InvalidRequest() - => new() - { - Status = SessionRefreshStatus.InvalidRequest - }; + public static SessionRefreshResult ReauthRequired() + => new() + { + Status = SessionRefreshStatus.ReauthRequired + }; - public static SessionRefreshResult Failed() + public static SessionRefreshResult InvalidRequest() => new() { - Status = SessionRefreshStatus.Failed + Status = SessionRefreshStatus.InvalidRequest }; - } + public static SessionRefreshResult Failed() + => new() + { + Status = SessionRefreshStatus.Failed + }; + } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs deleted file mode 100644 index 8517fccb..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - // TODO: IsNewChain, IsNewRoot flags? - /// - /// Represents the result of a session operation within UltimateAuth, such as - /// login or session refresh. - /// - /// A session operation may produce: - /// - a newly created session, - /// - an updated session chain (rotation), - /// - an updated session root (e.g., after adding a new chain). - /// - /// This wrapper provides a unified model so downstream components — such as - /// token services, event emitters, logging pipelines, or application-level - /// consumers — can easily access all updated authentication structures. - /// - public sealed class SessionResult - { - /// - /// Gets the active session produced by the operation. - /// This is the newest session and the one that should be used when issuing tokens. - /// - public required ISession Session { get; init; } - - /// - /// Gets the session chain associated with the session. - /// The chain may be newly created (login) or updated (session rotation). - /// - public required ISessionChain Chain { get; init; } - - /// - /// Gets the user's session root. - /// This structure may be updated when new chains are added or when security - /// properties change. - /// - public required ISessionRoot Root { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs index 0d23664c..223176fe 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -1,15 +1,15 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionRotationContext { - public sealed record SessionRotationContext - { - public string? TenantId { 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 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; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs index a16d81ca..5e52414e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs @@ -1,18 +1,16 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record SessionSecurityContext - { - public required UserKey? UserKey { get; init; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - public required AuthSessionId SessionId { get; init; } +public sealed record SessionSecurityContext +{ + public required UserKey? UserKey { get; init; } - public SessionState State { get; init; } + public required AuthSessionId SessionId { get; init; } - public SessionChainId? ChainId { get; init; } + public SessionState State { get; init; } - public DeviceId? BoundDeviceId { 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 index 76b089a5..3bcda73b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs @@ -1,43 +1,43 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Context information required by the session store when +/// creating or rotating sessions. +/// +public sealed class SessionStoreContext { /// - /// Context information required by the session store when - /// creating or rotating sessions. + /// The authenticated user identifier. /// - public sealed class SessionStoreContext - { - /// - /// The authenticated user identifier. - /// - public required UserKey UserKey { get; init; } + public required UserKey UserKey { get; init; } - /// - /// The tenant identifier, if multi-tenancy is enabled. - /// - public string? TenantId { 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; } + /// + /// 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; } + /// + /// 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; } + /// + /// The UTC timestamp when the session was issued. + /// + public DateTimeOffset IssuedAt { get; init; } - /// - /// Optional device or client identifier. - /// - public required DeviceContext Device { 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 index f7f42262..820f19fd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs @@ -1,15 +1,14 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum SessionTouchMode { - public enum SessionTouchMode - { - /// - /// Touch only if store policy allows (interval, throttling, etc.) - /// - IfNeeded, + /// + /// Touch only if store policy allows (interval, throttling, etc.) + /// + IfNeeded, - /// - /// Always update session activity, ignoring store heuristics. - /// - Force - } + /// + /// Always update session activity, ignoring store heuristics. + /// + Force } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs index bcae9016..6a493e06 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs @@ -1,12 +1,12 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionValidationContext { - public sealed record SessionValidationContext - { - public string? TenantId { get; init; } - public AuthSessionId SessionId { get; init; } - public DateTimeOffset Now { get; init; } - public required DeviceContext Device { get; init; } - } + 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 index d760b286..45e4e04b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -1,66 +1,65 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed class SessionValidationResult - { - public string? TenantId { get; init; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - public required SessionState State { get; init; } +public sealed class SessionValidationResult +{ + public TenantKey Tenant { get; init; } - public UserKey? UserKey { get; init; } + public required SessionState State { get; init; } - public AuthSessionId? SessionId { get; init; } + public UserKey? UserKey { get; init; } - public SessionChainId? ChainId { get; init; } + public AuthSessionId? SessionId { get; init; } - public SessionRootId? RootId { get; init; } + public SessionChainId? ChainId { get; init; } - public DeviceId? BoundDeviceId { get; init; } + public SessionRootId? RootId { get; init; } - public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; + public DeviceId? BoundDeviceId { get; init; } - public bool IsValid => State == SessionState.Active; + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; - private SessionValidationResult() { } + public bool IsValid => State == SessionState.Active; - public static SessionValidationResult Active( - string? tenantId, - UserKey? userId, - AuthSessionId sessionId, - SessionChainId chainId, - SessionRootId rootId, - ClaimsSnapshot claims, - DeviceId? boundDeviceId = null) - => new() - { - TenantId = tenantId, - State = SessionState.Active, - UserKey = userId, - SessionId = sessionId, - ChainId = chainId, - RootId = rootId, - Claims = claims, - BoundDeviceId = boundDeviceId - }; + private SessionValidationResult() { } - public static SessionValidationResult Invalid( - SessionState state, - UserKey? userId = null, - AuthSessionId? sessionId = null, - SessionChainId? chainId = null, - SessionRootId? rootId = null, - DeviceId? boundDeviceId = null) + public static SessionValidationResult Active( + TenantKey tenant, + UserKey? userId, + AuthSessionId sessionId, + SessionChainId chainId, + SessionRootId rootId, + ClaimsSnapshot claims, + DeviceId? boundDeviceId = null) => new() { - TenantId = null, - State = state, + Tenant = tenant, + State = SessionState.Active, UserKey = userId, SessionId = sessionId, ChainId = chainId, RootId = rootId, - Claims = ClaimsSnapshot.Empty, + Claims = claims, 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 index 32843501..459c8d0d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs @@ -1,32 +1,31 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents an issued access token (JWT or opaque). +/// +public sealed class AccessToken { /// - /// Represents an issued access token (JWT or opaque). + /// The actual token value sent to the client. /// - public sealed class AccessToken - { - /// - /// The actual token value sent to the client. - /// - public required string Token { get; init; } + 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; } + // 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; } + /// + /// 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; } + /// + /// Optional session id this token is bound to (Hybrid / SemiHybrid). + /// + public string? SessionId { get; init; } - public string? Scope { 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 index 344fedd9..be61e290 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs @@ -1,17 +1,16 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +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 { /// - /// 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. + /// The issued access token. + /// Always present when is returned. /// - public sealed record AuthTokens - { - /// - /// The issued access token. - /// Always present when is returned. - /// - public AccessToken AccessToken { get; init; } = default!; + public AccessToken AccessToken { get; init; } = default!; - public RefreshToken? RefreshToken { get; init; } - } + public RefreshToken? RefreshToken { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs deleted file mode 100644 index ed13a6ae..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using System.Security.Claims; - -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed class OpaqueTokenRecord - { - public string TokenHash { get; init; } = default!; - public string UserId { get; init; } = default!; - public string? TenantId { get; init; } - public AuthSessionId? SessionId { get; init; } - public DateTimeOffset ExpiresAt { get; init; } - public bool IsRevoked { get; init; } - public DateTimeOffset? RevokedAt { get; init; } - public IReadOnlyCollection Claims { get; init; } = Array.Empty(); - } - -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs index cb43d693..be3a120c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs @@ -1,23 +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; - } +namespace CodeBeam.UltimateAuth.Core.Contracts; - public static PrimaryToken FromSession(AuthSessionId sessionId) - => new(PrimaryTokenKind.Session, sessionId.ToString()); +public sealed record PrimaryToken +{ + public PrimaryTokenKind Kind { get; } + public string Value { get; } - public static PrimaryToken FromAccessToken(AccessToken token) - => new(PrimaryTokenKind.AccessToken, token.Token); + 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 index 821c3d19..0ef2e9f6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum PrimaryTokenKind { - public enum PrimaryTokenKind - { - Session = 1, - AccessToken = 2 - } + Session = 1, + AccessToken = 2 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs index 54306e68..d741b858 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs @@ -1,23 +1,22 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Transport model for refresh token. Returned to client once upon creation. +/// +public sealed class RefreshToken { /// - /// Transport model for refresh token. Returned to client once upon creation. + /// Plain refresh token value (returned to client once). /// - public sealed class RefreshToken - { - /// - /// Plain refresh token value (returned to client once). - /// - public required string Token { get; init; } + public required string Token { get; init; } - /// - /// Hash of the refresh token to be persisted. - /// - public required string TokenHash { get; init; } + /// + /// Hash of the refresh token to be persisted. + /// + public required string TokenHash { get; init; } - /// - /// Expiration time. - /// - public required DateTimeOffset ExpiresAt { get; init; } - } + /// + /// Expiration time. + /// + public required DateTimeOffset ExpiresAt { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs deleted file mode 100644 index 8e4cefc1..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public enum RefreshTokenFailureReason - { - Invalid, - Expired, - Revoked, - Reused - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs index e565b329..76c63775 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs @@ -1,15 +1,15 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshTokenRotationExecution { - public sealed record RefreshTokenRotationExecution - { - public RefreshTokenRotationResult Result { get; init; } = default!; + 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 string? TenantId { get; init; } - } + // 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/RefreshTokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs index e9423502..8ab8977b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs @@ -1,63 +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; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - public string? TokenHash { get; init; } +public sealed record RefreshTokenValidationResult +{ + public bool IsValid { get; init; } + public bool IsReuseDetected { get; init; } - public string? TenantId { get; init; } - public UserKey? UserKey { get; init; } - public AuthSessionId? SessionId { get; init; } - public SessionChainId? ChainId { get; init; } + public string? TokenHash { get; init; } - public DateTimeOffset? ExpiresAt { 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 - }; + private RefreshTokenValidationResult() { } - public static RefreshTokenValidationResult ReuseDetected( - string? tenantId = null, - AuthSessionId? sessionId = null, - string? tokenHash = null, - SessionChainId? chainId = null, - UserKey? userKey = default) + public static RefreshTokenValidationResult Invalid() => new() { IsValid = false, - IsReuseDetected = true, - TenantId = tenantId, - SessionId = sessionId, - TokenHash = tokenHash, - ChainId = chainId, - UserKey = userKey, + IsReuseDetected = false }; - public static RefreshTokenValidationResult Valid( - string? tenantId, - UserKey userKey, - AuthSessionId sessionId, - string? tokenHash, - SessionChainId? chainId = null) - => new() - { - IsValid = true, - IsReuseDetected = false, - TenantId = tenantId, - UserKey = userKey, - SessionId = sessionId, - ChainId = chainId, - TokenHash = tokenHash - }; - } + 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 index b36c1df6..3e17fc19 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +// It's not primary token kind, it's about transport format. +public enum TokenFormat { - // It's not primary token kind, it's about transport format. - public enum TokenFormat - { - Opaque = 1, - Jwt = 2 - } + Opaque = 1, + Jwt = 2 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs index 96ce78b1..5e80df5d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs @@ -1,16 +1,15 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum TokenInvalidReason { - public enum TokenInvalidReason - { - Invalid, - Expired, - Revoked, - Malformed, - SignatureInvalid, - AudienceMismatch, - IssuerMismatch, - MissingSubject, - Unknown, - NotImplemented - } + Invalid, + Expired, + Revoked, + Malformed, + SignatureInvalid, + AudienceMismatch, + IssuerMismatch, + MissingSubject, + Unknown, + NotImplemented } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs index f070cd12..fb95004f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs @@ -1,14 +1,14 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record TokenIssuanceContext { - public sealed record TokenIssuanceContext - { - public required UserKey UserKey { get; init; } - public string? TenantId { get; init; } - public IReadOnlyDictionary Claims { get; set; } = new Dictionary(); - public AuthSessionId? SessionId { get; init; } - public SessionChainId? ChainId { get; init; } - public DateTimeOffset IssuedAt { get; init; } - } + 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/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs index d7428ae7..d440a7e6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record TokenIssueContext { - public sealed record TokenIssueContext - { - public string? TenantId { get; init; } - public ISession Session { get; init; } = default!; - public DateTimeOffset At { get; init; } - } + public TenantKey Tenant { get; init; } + public UAuthSession Session { get; init; } = default!; + public DateTimeOffset At { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs index 95074428..2796946b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs @@ -1,9 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record TokenRefreshContext { - public sealed record TokenRefreshContext - { - public string? TenantId { get; init; } + public TenantKey Tenant { get; init; } - public string RefreshToken { get; init; } = default!; - } + 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 index dc94f72e..1c26c007 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs @@ -1,10 +1,8 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public enum TokenType - { - Opaque, - Jwt, - Unknown - } +namespace CodeBeam.UltimateAuth.Core.Contracts; +public enum TokenType +{ + Opaque, + Jwt, + Unknown } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs index 15485523..f0247ddf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs @@ -1,67 +1,67 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Security.Claims; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record TokenValidationResult { - public sealed record TokenValidationResult - { - public bool IsValid { get; init; } - public TokenType Type { get; init; } - public string? TenantId { 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; } + 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, - string? tenantId, - TUserId? userId, - AuthSessionId? sessionId, - IReadOnlyCollection? claims, - TokenInvalidReason? invalidReason, - DateTimeOffset? expiresAt - ) - { - IsValid = isValid; - TenantId = tenantId; - UserId = userId; - SessionId = sessionId; - Claims = claims ?? Array.Empty(); - InvalidReason = invalidReason; - ExpiresAt = expiresAt; - } + 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, - string? tenantId, - TUserId userId, - AuthSessionId? sessionId, - IReadOnlyCollection claims, - DateTimeOffset? expiresAt) - => new( - isValid: true, - type, - tenantId, - userId, - sessionId, - claims, - invalidReason: null, - 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, - tenantId: null, - userId: default, - sessionId: null, - claims: null, - invalidReason: reason, - expiresAt: null - ); - } + 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 index e921add4..d2964274 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public readonly struct Unit { - public readonly struct Unit - { - public static readonly Unit Value = new(); - } + 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 index ef8cdcce..2f61fa68 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs @@ -1,28 +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; } +namespace CodeBeam.UltimateAuth.Core.Contracts; - private AuthUserSnapshot(bool isAuthenticated, TUserId? userId) - { - IsAuthenticated = isAuthenticated; - UserId = userId; - } +// 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; } - public static AuthUserSnapshot Authenticated(TUserId userId) => new(true, userId); - public static AuthUserSnapshot Anonymous() => new(false, default); + 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 index 64e2c34b..a105c336 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs @@ -1,26 +1,25 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class UserAuthenticationResult { - public sealed class UserAuthenticationResult - { - public bool Succeeded { get; init; } + public bool Succeeded { get; init; } - public TUserId? UserId { get; init; } + public TUserId? UserId { get; init; } - public ClaimsSnapshot? Claims { get; init; } + public ClaimsSnapshot? Claims { get; init; } - public bool RequiresMfa { get; init; } + public bool RequiresMfa { get; init; } - public static UserAuthenticationResult Fail() => new() { Succeeded = false }; + 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 - }; - } + 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 index 2063d0c1..5a20021b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs @@ -1,14 +1,13 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class UserContext { - public sealed class UserContext - { - public TUserId? UserId { get; init; } - public IAuthSubject? User { get; init; } + public TUserId? UserId { get; init; } + public IAuthSubject? User { get; init; } - public bool IsAuthenticated => UserId is not null; + public bool IsAuthenticated => UserId is not null; - public static UserContext Anonymous() => new(); - } + public static UserContext Anonymous() => new(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs deleted file mode 100644 index fc1dd7ef..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - /// - /// Request to validate user credentials. - /// Used during login flows. - /// - public sealed class ValidateCredentialsRequest - { - /// - /// User identifier (same value used during registration). - /// - public required string Identifier { get; init; } - - /// - /// Plain-text password provided by the user. - /// - public required string Password { get; init; } - - /// - /// Optional tenant identifier. - /// - public string? TenantId { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs index 8d077b7b..905d54df 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs @@ -1,31 +1,30 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthFlowType { - public enum AuthFlowType - { - Login, - Reauthentication, + Login, + Reauthentication, - Logout, - RefreshSession, - ValidateSession, + Logout, + RefreshSession, + ValidateSession, - IssueToken, - RefreshToken, - IntrospectToken, - RevokeToken, + IssueToken, + RefreshToken, + IntrospectToken, + RevokeToken, - QuerySession, - RevokeSession, + QuerySession, + RevokeSession, - UserInfo, - PermissionQuery, + UserInfo, + PermissionQuery, - UserManagement, - UserProfileManagement, - UserIdentifierManagement, - CredentialManagement, - AuthorizationManagement, + UserManagement, + UserProfileManagement, + UserIdentifierManagement, + CredentialManagement, + AuthorizationManagement, - ApiAccess - } + ApiAccess } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs index e345dfb9..fcc44dde 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -1,27 +1,23 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - public sealed class DeviceContext - { - public DeviceId? DeviceId { get; init; } +namespace CodeBeam.UltimateAuth.Core.Domain; - public bool HasDeviceId => DeviceId is not null; +public sealed class DeviceContext +{ + public DeviceId? DeviceId { get; init; } - private DeviceContext(DeviceId? deviceId) - { - DeviceId = deviceId; - } + public bool HasDeviceId => DeviceId is not null; - public static DeviceContext Anonymous() - => new(null); + private DeviceContext(DeviceId? deviceId) + { + DeviceId = deviceId; + } - public static DeviceContext FromDeviceId(DeviceId deviceId) - => new(deviceId); + public static DeviceContext Anonymous() => new(null); - // 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. - // IP, Geo, Fingerprint, Platform, UA will be added here. + public static DeviceContext FromDeviceId(DeviceId deviceId) => new(deviceId); - } + // 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. + // IP, Geo, Fingerprint, Platform, UA will be added here. } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs index 0c059345..458ca78f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs @@ -1,11 +1,10 @@ using CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class HubCredentials { - public sealed class HubCredentials - { - public string AuthorizationCode { get; init; } = default!; - public string CodeVerifier { get; init; } = default!; - public UAuthClientProfile ClientProfile { get; init; } - } + 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/HubFlowArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs index 98704a10..3d6c99a0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Core.Domain; @@ -8,7 +9,7 @@ public sealed class HubFlowArtifact : AuthArtifact public HubFlowType FlowType { get; } public UAuthClientProfile ClientProfile { get; } - public string? TenantId { get; } + public TenantKey Tenant { get; } public string? ReturnUrl { get; } public HubFlowPayload Payload { get; } @@ -17,7 +18,7 @@ public HubFlowArtifact( HubSessionId hubSessionId, HubFlowType flowType, UAuthClientProfile clientProfile, - string? tenantId, + TenantKey tenant, string? returnUrl, HubFlowPayload payload, DateTimeOffset expiresAt) @@ -26,7 +27,7 @@ public HubFlowArtifact( HubSessionId = hubSessionId; FlowType = flowType; ClientProfile = clientProfile; - TenantId = tenantId; + Tenant = tenant; ReturnUrl = returnUrl; Payload = payload; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs index 344f1a6c..b45b995e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs @@ -1,18 +1,16 @@ 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; } +namespace CodeBeam.UltimateAuth.Core.Domain; - public bool IsActive { get; init; } - public bool IsExpired { get; init; } - public bool IsCompleted { get; init; } - public bool Exists { get; init; } - } +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 bool IsActive { get; init; } + public bool IsExpired { get; init; } + public bool IsCompleted { get; init; } + public bool Exists { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs index 7f34bcff..ace40a14 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs @@ -1,11 +1,24 @@ namespace CodeBeam.UltimateAuth.Core.Domain; // TODO: Bind id with IP and UA -public readonly record struct HubSessionId(string Value) +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 override string ToString() => Value; + 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) { @@ -20,4 +33,6 @@ public static bool TryParse(string? value, out HubSessionId sessionId) sessionId = new HubSessionId(value); return true; } + + public override string ToString() => Value; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs index 24f16326..7d3ba4cd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs @@ -1,14 +1,13 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthFailureReason { - public enum AuthFailureReason - { - InvalidCredentials, - LockedOut, - RequiresMfa, - SessionExpired, - SessionRevoked, - TenantDisabled, - Unauthorized, - Unknown - } + InvalidCredentials, + LockedOut, + RequiresMfa, + SessionExpired, + SessionRevoked, + TenantDisabled, + Unauthorized, + Unknown } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs index 6ceca575..399c1cdd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs @@ -1,39 +1,38 @@ using System.Security.Claims; -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class ClaimsSnapshotBuilder { - public sealed class ClaimsSnapshotBuilder - { - private readonly Dictionary> _claims = new(StringComparer.Ordinal); + private readonly Dictionary> _claims = new(StringComparer.Ordinal); - public ClaimsSnapshotBuilder Add(string type, string value) + public ClaimsSnapshotBuilder Add(string type, string value) + { + if (!_claims.TryGetValue(type, out var set)) { - if (!_claims.TryGetValue(type, out var set)) - { - set = new HashSet(StringComparer.Ordinal); - _claims[type] = set; - } - - set.Add(value); - return this; + set = new HashSet(StringComparer.Ordinal); + _claims[type] = set; } - public ClaimsSnapshotBuilder AddMany(string type, IEnumerable values) - { - foreach (var v in values) - Add(type, v); + set.Add(value); + return this; + } - return this; - } + public ClaimsSnapshotBuilder AddMany(string type, IEnumerable values) + { + foreach (var v in values) + Add(type, v); - public ClaimsSnapshotBuilder AddRole(string role) => Add(ClaimTypes.Role, role); + return this; + } - public ClaimsSnapshotBuilder AddPermission(string permission) => Add("uauth:permission", permission); + public ClaimsSnapshotBuilder AddRole(string role) => Add(ClaimTypes.Role, role); - public ClaimsSnapshot Build() - { - var frozen = _claims.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal); - return new ClaimsSnapshot(frozen); - } + 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/CredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs index 0bae1432..38e076be 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum CredentialKind { - public enum CredentialKind - { - Session, - AccessToken, - RefreshToken - } + Session, + AccessToken, + RefreshToken } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs index e791ae67..e5ddc547 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum PrimaryCredentialKind { - public enum PrimaryCredentialKind - { - Stateful, - Stateless - } + Stateful, + Stateless } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs index 3bf0cd41..315337c0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum ReauthBehavior { - public enum ReauthBehavior - { - RedirectToLogin, - None, - RaiseEvent - } + RedirectToLogin, + None, + RaiseEvent } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/UAuthClaim.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/UAuthClaim.cs deleted file mode 100644 index c0b05117..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/UAuthClaim.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - public sealed record UAuthClaim(string Type, string Value); -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index 8663562d..ec3cdcfd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -1,35 +1,34 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +// AuthSessionId is a opaque token, because it's more sensitive data. SessionChainId and SessionRootId are Guid. +public readonly record struct AuthSessionId { - // AuthSessionId is a opaque token, because it's more sensitive data. SessionChainId and SessionRootId are Guid. - public readonly record struct AuthSessionId + public string Value { get; } + + private AuthSessionId(string value) { - public string Value { get; } + Value = value; + } - private AuthSessionId(string value) + public static bool TryCreate(string? raw, out AuthSessionId id) + { + if (string.IsNullOrWhiteSpace(raw)) { - Value = value; + id = default; + return false; } - public static bool TryCreate(string raw, out AuthSessionId id) + if (raw.Length < 32) { - if (string.IsNullOrWhiteSpace(raw)) - { - id = default; - return false; - } - - if (raw.Length < 32) - { - id = default; - return false; - } - - id = new AuthSessionId(raw); - return true; + id = default; + return false; } - public override string ToString() => Value; - - public static implicit operator string(AuthSessionId id) => id.Value; + id = new AuthSessionId(raw); + 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 index c6f5f7bd..6d497ba9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -1,173 +1,172 @@ using System.Security.Claims; using System.Text.Json.Serialization; -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class ClaimsSnapshot { - public sealed class ClaimsSnapshot + private readonly IReadOnlyDictionary> _claims; + public IReadOnlyDictionary> Claims => _claims; + + [JsonConstructor] + public ClaimsSnapshot(IReadOnlyDictionary> claims) { - private readonly IReadOnlyDictionary> _claims; - public IReadOnlyDictionary> Claims => _claims; + _claims = claims; + } - [JsonConstructor] - public ClaimsSnapshot(IReadOnlyDictionary> claims) - { - _claims = claims; - } + public static ClaimsSnapshot Empty { get; } = new(new Dictionary>()); - 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!; - 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; - if (!Claims.TryGetValue(type, out var values)) - return false; + var first = values.FirstOrDefault(); + if (first is null) + 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(); - 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 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 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); - 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); - /// - /// Flattens claims by taking the first value of each claim. - /// Useful for logging, diagnostics, or legacy consumers. - /// - public IReadOnlyDictionary AsDictionary() + foreach (var (type, values) in Claims) { - var dict = new Dictionary(StringComparer.Ordinal); + var first = values.FirstOrDefault(); + if (first is not null) + dict[type] = first; + } - foreach (var (type, values) in Claims) - { - var first = values.FirstOrDefault(); - if (first is not null) - dict[type] = first; - } + return dict; + } - return dict; - } + public override bool Equals(object? obj) + { + if (obj is not ClaimsSnapshot other) + return false; - public override bool Equals(object? obj) + if (Claims.Count != other.Claims.Count) + return false; + + foreach (var (type, values) in Claims) { - if (obj is not ClaimsSnapshot other) + if (!other.Claims.TryGetValue(type, out var otherValues)) return false; - if (Claims.Count != other.Claims.Count) + if (values.Count != otherValues.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; + if (!values.All(v => otherValues.Contains(v))) + return false; } - public override int GetHashCode() + return true; + } + + public override int GetHashCode() + { + unchecked { - unchecked + int hash = 17; + + foreach (var (type, values) in Claims.OrderBy(x => x.Key)) { - int hash = 17; + hash = hash * 23 + type.GetHashCode(); - foreach (var (type, values) in Claims.OrderBy(x => x.Key)) + foreach (var value in values.OrderBy(v => v)) { - hash = hash * 23 + type.GetHashCode(); - - foreach (var value in values.OrderBy(v => v)) - { - hash = hash * 23 + value.GetHashCode(); - } + hash = hash * 23 + value.GetHashCode(); } - - return hash; } + + return hash; } + } - public static ClaimsSnapshot From(params (string Type, string Value)[] claims) - { - var dict = new Dictionary>(StringComparer.Ordinal); + public static ClaimsSnapshot From(params (string Type, string Value)[] claims) + { + var dict = new Dictionary>(StringComparer.Ordinal); - foreach (var (type, value) in claims) + foreach (var (type, value) in claims) + { + if (!dict.TryGetValue(type, out var set)) { - if (!dict.TryGetValue(type, out var set)) - { - set = new HashSet(StringComparer.Ordinal); - dict[type] = set; - } - - set.Add(value); + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; } - return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + set.Add(value); } - public ClaimsSnapshot With(params (string Type, string Value)[] claims) - { - if (claims.Length == 0) - return this; + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + } - var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); + public ClaimsSnapshot With(params (string Type, string Value)[] claims) + { + if (claims.Length == 0) + return this; - foreach (var (type, value) in claims) - { - if (!dict.TryGetValue(type, out var set)) - { - set = new HashSet(StringComparer.Ordinal); - dict[type] = set; - } + var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); - set.Add(value); + foreach (var (type, value) in claims) + { + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; } - return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + set.Add(value); } - public ClaimsSnapshot Merge(ClaimsSnapshot other) - { - if (other is null || other.Claims.Count == 0) - return this; + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + } - if (Claims.Count == 0) - return other; + public ClaimsSnapshot Merge(ClaimsSnapshot other) + { + if (other is null || other.Claims.Count == 0) + return this; - var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); + if (Claims.Count == 0) + return other; - foreach (var (type, values) in other.Claims) - { - if (!dict.TryGetValue(type, out var set)) - { - set = new HashSet(StringComparer.Ordinal); - dict[type] = set; - } + var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); - foreach (var value in values) - set.Add(value); + foreach (var (type, values) in other.Claims) + { + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; } - return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + foreach (var value in values) + set.Add(value); } - public static ClaimsSnapshotBuilder Create() => new ClaimsSnapshotBuilder(); - + 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/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs deleted file mode 100644 index ac2f975a..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ /dev/null @@ -1,86 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Domain -{ - /// - /// Represents a single authentication session belonging to a user. - /// Sessions are immutable, security-critical units used for validation, - /// sliding expiration, revocation, and device analytics. - /// - public interface ISession - { - /// - /// Gets the unique identifier of the session. - /// - AuthSessionId SessionId { get; } - - string? TenantId { get; } - - /// - /// Gets the identifier of the user who owns this session. - /// - UserKey UserKey { get; } - - SessionChainId ChainId { get; } - - /// - /// Gets the timestamp when this session was originally created. - /// - DateTimeOffset CreatedAt { get; } - - /// - /// Gets the timestamp when the session becomes invalid due to expiration. - /// - DateTimeOffset ExpiresAt { get; } - - /// - /// Gets the timestamp of the last successful usage. - /// Used when evaluating sliding expiration policies. - /// - DateTimeOffset? LastSeenAt { get; } - - /// - /// Gets a value indicating whether this session has been explicitly revoked. - /// - bool IsRevoked { get; } - - /// - /// Gets the timestamp when the session was revoked, if applicable. - /// - DateTimeOffset? RevokedAt { get; } - - /// - /// Gets the user's security version at the moment of session creation. - /// If the stored version does not match the user's current version, - /// the session becomes invalid (e.g., after password or MFA reset). - /// - long SecurityVersionAtCreation { get; } - - /// - /// Gets metadata describing the client device that created the session. - /// Includes platform, OS, IP address, fingerprint, and more. - /// - DeviceContext Device { get; } - - ClaimsSnapshot Claims { get; } - - /// - /// Gets session-scoped metadata used for application-specific extensions, - /// such as tenant data, app version, locale, or CSRF tokens. - /// - SessionMetadata Metadata { get; } - - /// - /// Computes the effective runtime state of the session (Active, Expired, - /// Revoked, SecurityVersionMismatch, etc.) based on the provided timestamp. - /// - /// The evaluated of this session. - SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout); - - ISession Touch(DateTimeOffset now); - ISession Revoke(DateTimeOffset at); - - ISession WithChain(SessionChainId chainId); - - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs deleted file mode 100644 index 7659f475..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - /// - /// Represents a device- or login-scoped session chain. - /// A chain groups all rotated sessions belonging to a single logical login - /// (e.g., a browser instance, mobile app installation, or device fingerprint). - /// - public interface ISessionChain - { - /// - /// Gets the unique identifier of the session chain. - /// - SessionChainId ChainId { get; } - - SessionRootId RootId { get; } - - string? TenantId { get; } - - /// - /// Gets the identifier of the user who owns this chain. - /// Each chain represents one device/login family for this user. - /// - UserKey UserKey { get; } - - /// - /// Gets the number of refresh token rotations performed within this chain. - /// - int RotationCount { get; } - - /// - /// Gets the user's security version at the time the chain was created. - /// If the user's current security version is higher, the entire chain - /// becomes invalid (e.g., after password or MFA reset). - /// - long SecurityVersionAtCreation { get; } - - /// - /// Gets an optional snapshot of claims taken at chain creation time. - /// Useful for offline clients, WASM apps, and environments where - /// full user lookup cannot be performed on each request. - /// - ClaimsSnapshot ClaimsSnapshot { get; } - - /// - /// Gets the identifier of the currently active authentication session, if one exists. - /// - AuthSessionId? ActiveSessionId { get; } - - /// - /// Gets a value indicating whether this chain has been revoked. - /// Revoking a chain performs a device-level logout, invalidating - /// all sessions it contains. - /// - bool IsRevoked { get; } - - /// - /// Gets the timestamp when the chain was revoked, if applicable. - /// - DateTimeOffset? RevokedAt { get; } - - ISessionChain AttachSession(AuthSessionId sessionId); - ISessionChain RotateSession(AuthSessionId sessionId); - ISessionChain Revoke(DateTimeOffset at); - } - -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs deleted file mode 100644 index b839292a..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - /// - /// Represents the root container for all authentication session chains of a user. - /// A session root is tenant-scoped and acts as the authoritative security boundary, - /// controlling global revocation, security versioning, and device/login families. - /// - public interface ISessionRoot - { - SessionRootId RootId { get; } - - /// - /// Gets the tenant identifier associated with this session root. - /// Used to isolate authentication domains in multi-tenant systems. - /// - string? TenantId { get; } - - /// - /// Gets the identifier of the user who owns this session root. - /// Each user has one root per tenant. - /// - UserKey UserKey { get; } - - /// - /// Gets a value indicating whether the entire session root is revoked. - /// When true, all chains and sessions belonging to this root are invalid, - /// regardless of their individual states. - /// - bool IsRevoked { get; } - - /// - /// Gets the timestamp when the session root was revoked, if applicable. - /// - DateTimeOffset? RevokedAt { get; } - - /// - /// Gets the current security version of the user within this tenant. - /// Incrementing this value invalidates all sessions, even if they are still active. - /// Common triggers include password reset, MFA reset, and account recovery. - /// - long SecurityVersion { get; } - - /// - /// Gets the complete set of session chains associated with this root. - /// Each chain represents a device or login-family (browser instance, mobile app, etc.). - /// The root is immutable; modifications must go through SessionService or SessionStore. - /// - IReadOnlyList Chains { get; } - - /// - /// Gets the timestamp when this root structure was last updated. - /// Useful for caching, concurrency handling, and incremental synchronization. - /// - DateTimeOffset LastUpdatedAt { get; } - - ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at); - - ISessionRoot Revoke(DateTimeOffset at); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs index dec5c2f1..f0aa8db4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs @@ -1,11 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum RefreshOutcome { - public enum RefreshOutcome - { - None, - NoOp, - Touched, - Rotated, - ReauthRequired - } + None, + NoOp, + Touched, + Rotated, + ReauthRequired } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs index 5c253e65..d2edc1d3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs @@ -1,33 +1,32 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public readonly record struct SessionChainId(Guid Value) { - public readonly record struct SessionChainId(Guid Value) - { - public static SessionChainId New() => new(Guid.NewGuid()); + 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); + /// + /// 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 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 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) + public static bool TryCreate(string raw, out SessionChainId id) + { + if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) { - if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) - { - id = new SessionChainId(guid); - return true; - } - - id = default; - return false; + id = new SessionChainId(guid); + return true; } - public override string ToString() => Value.ToString("N"); + id = default; + return false; } + + public override string ToString() => Value.ToString("N"); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs index ca81551a..899c3b03 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs @@ -1,42 +1,41 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +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 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. + /// Represents an empty or uninitialized session metadata instance. /// - 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(); + /// 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 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 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 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; } - } + /// + /// 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 index d8724ba1..1d4927fa 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum SessionRefreshStatus { - public enum SessionRefreshStatus - { - Success, - ReauthRequired, - InvalidRequest, - Failed - } + Success, + ReauthRequired, + InvalidRequest, + Failed } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs index 68d595a2..be7c1513 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs @@ -1,26 +1,25 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public readonly record struct SessionRootId(Guid Value) { - public readonly record struct SessionRootId(Guid Value) - { - public static SessionRootId New() => new(Guid.NewGuid()); + 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 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) + public static bool TryCreate(string raw, out SessionRootId id) + { + if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) { - if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) - { - id = new SessionRootId(guid); - return true; - } - - id = default; - return false; + id = new SessionRootId(guid); + return true; } - public override string ToString() => Value.ToString("N"); + id = 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 index 95a6af0a..a2c01f45 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs @@ -1,17 +1,16 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +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 { - /// - /// Represents the effective runtime state of an authentication session. - /// Evaluated based on expiration rules, revocation status, and security version checks. - /// - public enum SessionState - { - Active, - Expired, - Revoked, - NotFound, - Invalid, - SecurityMismatch, - DeviceMismatch - } + Active, + Expired, + Revoked, + NotFound, + Invalid, + SecurityMismatch, + DeviceMismatch } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 78786ef1..bcb8b2a2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,26 +1,146 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class UAuthSession { - public sealed class UAuthSession : ISession + 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? LastSeenAt { get; } + public bool IsRevoked { get; } + public DateTimeOffset? RevokedAt { get; } + public long SecurityVersionAtCreation { get; } + public DeviceContext Device { get; } + public ClaimsSnapshot Claims { get; } + public SessionMetadata Metadata { get; } + + private UAuthSession( + AuthSessionId sessionId, + TenantKey tenant, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + DateTimeOffset? lastSeenAt, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + DeviceContext device, + ClaimsSnapshot claims, + SessionMetadata metadata) + { + SessionId = sessionId; + Tenant = tenant; + UserKey = userKey; + ChainId = chainId; + CreatedAt = createdAt; + ExpiresAt = expiresAt; + LastSeenAt = lastSeenAt; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + SecurityVersionAtCreation = securityVersionAtCreation; + Device = device; + Claims = claims; + Metadata = metadata; + } + + public static UAuthSession Create( + AuthSessionId sessionId, + TenantKey tenant, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset now, + DateTimeOffset expiresAt, + DeviceContext device, + ClaimsSnapshot? claims, + SessionMetadata metadata) + { + return new( + sessionId, + tenant, + userKey, + chainId, + createdAt: now, + expiresAt: expiresAt, + lastSeenAt: now, + isRevoked: false, + revokedAt: null, + securityVersionAtCreation: 0, + device: device, + claims: claims ?? ClaimsSnapshot.Empty, + metadata: metadata + ); + } + + public UAuthSession WithSecurityVersion(long version) + { + if (SecurityVersionAtCreation == version) + return this; + + return new UAuthSession( + SessionId, + Tenant, + UserKey, + ChainId, + CreatedAt, + ExpiresAt, + LastSeenAt, + IsRevoked, + RevokedAt, + version, + Device, + Claims, + Metadata + ); + } + + public UAuthSession Touch(DateTimeOffset at) + { + return new UAuthSession( + SessionId, + Tenant, + UserKey, + ChainId, + CreatedAt, + ExpiresAt, + at, + IsRevoked, + RevokedAt, + SecurityVersionAtCreation, + Device, + Claims, + Metadata + ); + } + + public UAuthSession Revoke(DateTimeOffset at) { - public AuthSessionId SessionId { get; } - public string? TenantId { get; } - public UserKey UserKey { get; } - public SessionChainId ChainId { get; } - public DateTimeOffset CreatedAt { get; } - public DateTimeOffset ExpiresAt { get; } - public DateTimeOffset? LastSeenAt { get; } - public bool IsRevoked { get; } - public DateTimeOffset? RevokedAt { get; } - public long SecurityVersionAtCreation { get; } - public DeviceContext Device { get; } - public ClaimsSnapshot Claims { get; } - public SessionMetadata Metadata { get; } - - private UAuthSession( + if (IsRevoked) return this; + + return new UAuthSession( + SessionId, + Tenant, + UserKey, + ChainId, + CreatedAt, + ExpiresAt, + LastSeenAt, + true, + at, + SecurityVersionAtCreation, + Device, + Claims, + Metadata + ); + } + + internal static UAuthSession FromProjection( AuthSessionId sessionId, - string? tenantId, + TenantKey tenant, UserKey userKey, SessionChainId chainId, DateTimeOffset createdAt, @@ -32,180 +152,58 @@ private UAuthSession( DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata) - { - SessionId = sessionId; - TenantId = tenantId; - UserKey = userKey; - ChainId = chainId; - CreatedAt = createdAt; - ExpiresAt = expiresAt; - LastSeenAt = lastSeenAt; - IsRevoked = isRevoked; - RevokedAt = revokedAt; - SecurityVersionAtCreation = securityVersionAtCreation; - Device = device; - Claims = claims; - Metadata = metadata; - } - - public static UAuthSession Create( - AuthSessionId sessionId, - string? tenantId, - UserKey userKey, - SessionChainId chainId, - DateTimeOffset now, - DateTimeOffset expiresAt, - DeviceContext device, - ClaimsSnapshot claims, - SessionMetadata metadata) - { - return new( - sessionId, - tenantId, - userKey, - chainId, - createdAt: now, - expiresAt: expiresAt, - lastSeenAt: now, - isRevoked: false, - revokedAt: null, - securityVersionAtCreation: 0, - device: device, - claims: claims, - metadata: metadata - ); - } - - public UAuthSession WithSecurityVersion(long version) - { - if (SecurityVersionAtCreation == version) - return this; - - return new UAuthSession( - SessionId, - TenantId, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - LastSeenAt, - IsRevoked, - RevokedAt, - version, - Device, - Claims, - Metadata - ); - } - - public ISession Touch(DateTimeOffset at) - { - return new UAuthSession( - SessionId, - TenantId, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - at, - IsRevoked, - RevokedAt, - SecurityVersionAtCreation, - Device, - Claims, - Metadata - ); - } - - public ISession Revoke(DateTimeOffset at) - { - if (IsRevoked) return this; - - return new UAuthSession( - SessionId, - TenantId, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - LastSeenAt, - true, - at, - SecurityVersionAtCreation, - Device, - Claims, - Metadata - ); - } - - internal static UAuthSession FromProjection( - AuthSessionId sessionId, - string? tenantId, - UserKey userKey, - SessionChainId chainId, - DateTimeOffset createdAt, - DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, - bool isRevoked, - DateTimeOffset? revokedAt, - long securityVersionAtCreation, - DeviceContext device, - ClaimsSnapshot claims, - SessionMetadata metadata) - { - return new UAuthSession( - sessionId, - tenantId, - userKey, - chainId, - createdAt, - expiresAt, - lastSeenAt, - isRevoked, - revokedAt, - securityVersionAtCreation, - device, - claims, - metadata - ); - } - - public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) - { - if (IsRevoked) - return SessionState.Revoked; - - if (at >= ExpiresAt) - return SessionState.Expired; - - if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) - return SessionState.Expired; - - return SessionState.Active; - } - - public ISession WithChain(SessionChainId chainId) - { - if (!ChainId.IsUnassigned) - throw new InvalidOperationException("Chain already assigned."); - - return new UAuthSession( - sessionId: SessionId, - tenantId: TenantId, - userKey: UserKey, - chainId: chainId, - createdAt: CreatedAt, - expiresAt: ExpiresAt, - lastSeenAt: LastSeenAt, - isRevoked: IsRevoked, - revokedAt: RevokedAt, - securityVersionAtCreation: SecurityVersionAtCreation, - device: Device, - claims: Claims, - metadata: Metadata - ); - } + { + return new UAuthSession( + sessionId, + tenant, + userKey, + chainId, + createdAt, + expiresAt, + lastSeenAt, + isRevoked, + revokedAt, + securityVersionAtCreation, + device, + claims, + metadata + ); + } + public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) + { + if (IsRevoked) + return SessionState.Revoked; + + if (at >= ExpiresAt) + return SessionState.Expired; + + if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) + return SessionState.Expired; + + return SessionState.Active; + } + + public UAuthSession WithChain(SessionChainId chainId) + { + if (!ChainId.IsUnassigned) + throw new InvalidOperationException("Chain already assigned."); + + return new UAuthSession( + sessionId: SessionId, + tenant: Tenant, + userKey: UserKey, + chainId: chainId, + createdAt: CreatedAt, + expiresAt: ExpiresAt, + lastSeenAt: LastSeenAt, + isRevoked: IsRevoked, + revokedAt: RevokedAt, + securityVersionAtCreation: SecurityVersionAtCreation, + device: Device, + claims: Claims, + metadata: Metadata + ); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 9403f95e..dfecbcb3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,146 +1,147 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - public sealed class UAuthSessionChain : ISessionChain - { - public SessionChainId ChainId { get; } - public SessionRootId RootId { get; } - public string? TenantId { get; } - public UserKey UserKey { get; } - public int RotationCount { get; } - public long SecurityVersionAtCreation { get; } - public ClaimsSnapshot ClaimsSnapshot { get; } - public AuthSessionId? ActiveSessionId { get; } - public bool IsRevoked { get; } - public DateTimeOffset? RevokedAt { get; } +using CodeBeam.UltimateAuth.Core.MultiTenancy; - private UAuthSessionChain( - SessionChainId chainId, - SessionRootId rootId, - string? tenantId, - UserKey userKey, - int rotationCount, - long securityVersionAtCreation, - ClaimsSnapshot claimsSnapshot, - AuthSessionId? activeSessionId, - bool isRevoked, - DateTimeOffset? revokedAt) - { - ChainId = chainId; - RootId = rootId; - TenantId = tenantId; - UserKey = userKey; - RotationCount = rotationCount; - SecurityVersionAtCreation = securityVersionAtCreation; - ClaimsSnapshot = claimsSnapshot; - ActiveSessionId = activeSessionId; - IsRevoked = isRevoked; - RevokedAt = revokedAt; - } +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class UAuthSessionChain +{ + public SessionChainId ChainId { get; } + public SessionRootId RootId { get; } + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + public int RotationCount { get; } + public long SecurityVersionAtCreation { get; } + public ClaimsSnapshot ClaimsSnapshot { get; } + public AuthSessionId? ActiveSessionId { get; } + public bool IsRevoked { get; } + public DateTimeOffset? RevokedAt { get; } - public static UAuthSessionChain Create( - SessionChainId chainId, - SessionRootId rootId, - string? tenantId, - UserKey userKey, - long securityVersion, - ClaimsSnapshot claimsSnapshot) - { - return new UAuthSessionChain( - chainId, - rootId, - tenantId, - userKey, - rotationCount: 0, - securityVersionAtCreation: securityVersion, - claimsSnapshot: claimsSnapshot, - activeSessionId: null, - isRevoked: false, - revokedAt: null - ); - } + private UAuthSessionChain( + SessionChainId chainId, + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + int rotationCount, + long securityVersionAtCreation, + ClaimsSnapshot claimsSnapshot, + AuthSessionId? activeSessionId, + bool isRevoked, + DateTimeOffset? revokedAt) + { + ChainId = chainId; + RootId = rootId; + Tenant = tenant; + UserKey = userKey; + RotationCount = rotationCount; + SecurityVersionAtCreation = securityVersionAtCreation; + ClaimsSnapshot = claimsSnapshot; + ActiveSessionId = activeSessionId; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + } - public ISessionChain AttachSession(AuthSessionId sessionId) - { - if (IsRevoked) - return this; + public static UAuthSessionChain Create( + SessionChainId chainId, + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + long securityVersion, + ClaimsSnapshot claimsSnapshot) + { + return new UAuthSessionChain( + chainId, + rootId, + tenant, + userKey, + rotationCount: 0, + securityVersionAtCreation: securityVersion, + claimsSnapshot: claimsSnapshot, + activeSessionId: null, + isRevoked: false, + revokedAt: null + ); + } - return new UAuthSessionChain( - ChainId, - RootId, - TenantId, - UserKey, - RotationCount, // Unchanged on first attach - SecurityVersionAtCreation, - ClaimsSnapshot, - activeSessionId: sessionId, - isRevoked: false, - revokedAt: null - ); - } + public UAuthSessionChain AttachSession(AuthSessionId sessionId) + { + if (IsRevoked) + return this; - public ISessionChain RotateSession(AuthSessionId sessionId) - { - if (IsRevoked) - return this; + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + RotationCount, // Unchanged on first attach + SecurityVersionAtCreation, + ClaimsSnapshot, + activeSessionId: sessionId, + isRevoked: false, + revokedAt: null + ); + } - return new UAuthSessionChain( - ChainId, - RootId, - TenantId, - UserKey, - RotationCount + 1, - SecurityVersionAtCreation, - ClaimsSnapshot, - activeSessionId: sessionId, - isRevoked: false, - revokedAt: null - ); - } + public UAuthSessionChain RotateSession(AuthSessionId sessionId) + { + if (IsRevoked) + return this; - public ISessionChain Revoke(DateTimeOffset at) - { - if (IsRevoked) - return this; + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + RotationCount + 1, + SecurityVersionAtCreation, + ClaimsSnapshot, + activeSessionId: sessionId, + isRevoked: false, + revokedAt: null + ); + } - return new UAuthSessionChain( - ChainId, - RootId, - TenantId, - UserKey, - RotationCount, - SecurityVersionAtCreation, - ClaimsSnapshot, - ActiveSessionId, - isRevoked: true, - revokedAt: at - ); - } + public UAuthSessionChain Revoke(DateTimeOffset at) + { + if (IsRevoked) + return this; - internal static UAuthSessionChain FromProjection( - SessionChainId chainId, - SessionRootId rootId, - string? tenantId, - UserKey userKey, - int rotationCount, - long securityVersionAtCreation, - ClaimsSnapshot claimsSnapshot, - AuthSessionId? activeSessionId, - bool isRevoked, - DateTimeOffset? revokedAt) - { - return new UAuthSessionChain( - chainId, - rootId, - tenantId, - userKey, - rotationCount, - securityVersionAtCreation, - claimsSnapshot, - activeSessionId, - isRevoked, - revokedAt - ); - } + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + RotationCount, + SecurityVersionAtCreation, + ClaimsSnapshot, + ActiveSessionId, + isRevoked: true, + revokedAt: at + ); + } + internal static UAuthSessionChain FromProjection( + SessionChainId chainId, + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + int rotationCount, + long securityVersionAtCreation, + ClaimsSnapshot claimsSnapshot, + AuthSessionId? activeSessionId, + bool isRevoked, + DateTimeOffset? revokedAt) + { + return new UAuthSessionChain( + chainId, + rootId, + tenant, + userKey, + rotationCount, + securityVersionAtCreation, + claimsSnapshot, + activeSessionId, + isRevoked, + revokedAt + ); } + } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 0153210f..3eb85942 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -1,109 +1,110 @@ -namespace CodeBeam.UltimateAuth.Core.Domain -{ - public sealed class UAuthSessionRoot : ISessionRoot - { - public SessionRootId RootId { get; } - public UserKey UserKey { get; } - public string? TenantId { get; } - public bool IsRevoked { get; } - public DateTimeOffset? RevokedAt { get; } - public long SecurityVersion { get; } - public IReadOnlyList Chains { get; } - public DateTimeOffset LastUpdatedAt { get; } +using CodeBeam.UltimateAuth.Core.MultiTenancy; - private UAuthSessionRoot( - SessionRootId rootId, - string? tenantId, - UserKey userKey, - bool isRevoked, - DateTimeOffset? revokedAt, - long securityVersion, - IReadOnlyList chains, - DateTimeOffset lastUpdatedAt) - { - RootId = rootId; - TenantId = tenantId; - UserKey = userKey; - IsRevoked = isRevoked; - RevokedAt = revokedAt; - SecurityVersion = securityVersion; - Chains = chains; - LastUpdatedAt = lastUpdatedAt; - } +namespace CodeBeam.UltimateAuth.Core.Domain; - public static ISessionRoot Create( - string? tenantId, - UserKey userKey, - DateTimeOffset issuedAt) - { - return new UAuthSessionRoot( - SessionRootId.New(), - tenantId, - userKey, - isRevoked: false, - revokedAt: null, - securityVersion: 0, - chains: Array.Empty(), - lastUpdatedAt: issuedAt - ); - } +public sealed class UAuthSessionRoot +{ + public SessionRootId RootId { get; } + public UserKey UserKey { get; } + public TenantKey Tenant { get; } + public bool IsRevoked { get; } + public DateTimeOffset? RevokedAt { get; } + public long SecurityVersion { get; } + public IReadOnlyList Chains { get; } + public DateTimeOffset LastUpdatedAt { get; } - public ISessionRoot Revoke(DateTimeOffset at) - { - if (IsRevoked) - return this; + private UAuthSessionRoot( + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersion, + IReadOnlyList chains, + DateTimeOffset lastUpdatedAt) + { + RootId = rootId; + Tenant = tenant; + UserKey = userKey; + IsRevoked = isRevoked; + RevokedAt = revokedAt; + SecurityVersion = securityVersion; + Chains = chains; + LastUpdatedAt = lastUpdatedAt; + } - return new UAuthSessionRoot( - RootId, - TenantId, - UserKey, - isRevoked: true, - revokedAt: at, - securityVersion: SecurityVersion, - chains: Chains, - lastUpdatedAt: at - ); - } + public static UAuthSessionRoot Create( + TenantKey tenant, + UserKey userKey, + DateTimeOffset issuedAt) + { + return new UAuthSessionRoot( + SessionRootId.New(), + tenant, + userKey, + isRevoked: false, + revokedAt: null, + securityVersion: 0, + chains: Array.Empty(), + lastUpdatedAt: issuedAt + ); + } - public ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at) - { - if (IsRevoked) - return this; + public UAuthSessionRoot Revoke(DateTimeOffset at) + { + if (IsRevoked) + return this; - return new UAuthSessionRoot( - RootId, - TenantId, - UserKey, - IsRevoked, - RevokedAt, - SecurityVersion, - Chains.Concat(new[] { chain }).ToArray(), - at - ); - } + return new UAuthSessionRoot( + RootId, + Tenant, + UserKey, + isRevoked: true, + revokedAt: at, + securityVersion: SecurityVersion, + chains: Chains, + lastUpdatedAt: at + ); + } - internal static UAuthSessionRoot FromProjection( - SessionRootId rootId, - string? tenantId, - UserKey userKey, - bool isRevoked, - DateTimeOffset? revokedAt, - long securityVersion, - IReadOnlyList chains, - DateTimeOffset lastUpdatedAt) - { - return new UAuthSessionRoot( - rootId, - tenantId, - userKey, - isRevoked, - revokedAt, - securityVersion, - chains, - lastUpdatedAt - ); - } + public UAuthSessionRoot AttachChain(UAuthSessionChain chain, DateTimeOffset at) + { + if (IsRevoked) + return this; + return new UAuthSessionRoot( + RootId, + Tenant, + UserKey, + IsRevoked, + RevokedAt, + SecurityVersion, + Chains.Concat(new[] { chain }).ToArray(), + at + ); + } + internal static UAuthSessionRoot FromProjection( + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersion, + IReadOnlyList chains, + DateTimeOffset lastUpdatedAt) + { + return new UAuthSessionRoot( + rootId, + tenant, + userKey, + isRevoked, + revokedAt, + securityVersion, + chains, + lastUpdatedAt + ); } + + } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs index f1592b64..31cb67eb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs @@ -1,33 +1,33 @@ -using System.ComponentModel.DataAnnotations.Schema; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.ComponentModel.DataAnnotations.Schema; -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +/// +/// Represents a persisted refresh token bound to a session. +/// Stored as a hashed value for security reasons. +/// +public sealed record StoredRefreshToken { - /// - /// Represents a persisted refresh token bound to a session. - /// Stored as a hashed value for security reasons. - /// - public sealed record StoredRefreshToken - { - public string TokenHash { get; init; } = default!; + public string TokenHash { get; init; } = default!; - public string? TenantId { get; init; } + public TenantKey Tenant { get; init; } - public required UserKey UserKey { get; init; } + public required UserKey UserKey { get; init; } - public AuthSessionId SessionId { get; init; } = default!; - public SessionChainId? ChainId { get; init; } + public AuthSessionId SessionId { get; init; } = default!; + public SessionChainId? ChainId { get; init; } - public DateTimeOffset IssuedAt { get; init; } - public DateTimeOffset ExpiresAt { get; init; } - public DateTimeOffset? RevokedAt { get; init; } + public DateTimeOffset IssuedAt { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public DateTimeOffset? RevokedAt { get; init; } - public string? ReplacedByTokenHash { get; init; } + public string? ReplacedByTokenHash { get; init; } - [NotMapped] - public bool IsRevoked => RevokedAt.HasValue; + [NotMapped] + public bool IsRevoked => RevokedAt.HasValue; - public bool IsExpired(DateTimeOffset now) => ExpiresAt <= now; + public bool IsExpired(DateTimeOffset now) => ExpiresAt <= now; - public bool IsActive(DateTimeOffset now) => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; - } + public bool IsActive(DateTimeOffset now) => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs index 3961fbdb..1fe6ef8e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs @@ -1,24 +1,23 @@ -using System.Security.Claims; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +/// +/// Framework-agnostic JWT description used by IJwtTokenGenerator. +/// +public sealed class UAuthJwtTokenDescriptor { - /// - /// Framework-agnostic JWT description used by IJwtTokenGenerator. - /// - public sealed class UAuthJwtTokenDescriptor - { - public required string Subject { get; init; } + public required string Subject { get; init; } - public required string Issuer { get; init; } + public required string Issuer { get; init; } - public required string Audience { get; init; } + public required string Audience { get; init; } - public required DateTimeOffset IssuedAt { get; init; } - public required DateTimeOffset ExpiresAt { get; init; } - public string? TenantId { 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 IReadOnlyDictionary? Claims { get; init; } - public string? KeyId { get; init; } // kid - } + 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 index 97eec361..9099cbf6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs @@ -1,21 +1,20 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +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 { /// - /// 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. + /// Gets the unique identifier of the user. /// - public interface IAuthSubject - { - /// - /// Gets the unique identifier of the user. - /// - TUserId UserId { get; } + 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; } - } + /// + /// 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/ICurrentUser.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/ICurrentUser.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Domain/ICurrentUser.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/User/ICurrentUser.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs index 3e42de9d..e45d5220 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs @@ -1,69 +1,67 @@ using CodeBeam.UltimateAuth.Core.Infrastructure; using System.Text.Json.Serialization; -namespace CodeBeam.UltimateAuth.Core.Domain +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(UserKeyJsonConverter))] +public readonly record struct UserKey : IParsable { - [JsonConverter(typeof(UserKeyJsonConverter))] - public readonly record struct UserKey : IParsable - { - public string Value { get; } + public string Value { get; } - private UserKey(string value) - { - Value = value; - } + 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 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)); + /// + /// 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); - } + return new UserKey(value); + } - /// - /// Generates a new GUID-based UserKey. - /// - public static UserKey New() => FromGuid(Guid.NewGuid()); + /// + /// Generates a new GUID-based UserKey. + /// + public static UserKey New() => FromGuid(Guid.NewGuid()); - public static bool TryParse(string? s, IFormatProvider? provider, out UserKey result) + public static bool TryParse(string? s, IFormatProvider? provider, out UserKey result) + { + if (string.IsNullOrWhiteSpace(s)) { - 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; + result = default; + return false; } - public static UserKey Parse(string s, IFormatProvider? provider) + if (Guid.TryParse(s, out var guid)) { - if (!TryParse(s, provider, out var result)) - throw new FormatException($"Invalid UserKey value: '{s}'"); - - return result; + result = FromGuid(guid); + return true; } - public override string ToString() => Value; + result = FromString(s); + return true; + } - public static implicit operator string(UserKey key) => key.Value; + 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/Errors/Authorization/UAuthChallengeRequiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs index e927d419..892c6e06 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthChallengeRequiredException : UAuthException { - public sealed class UAuthChallengeRequiredException : UAuthException + public UAuthChallengeRequiredException(string? reason = null) + : base(reason ?? "Additional authentication is required to perform this operation.") { - public UAuthChallengeRequiredException(string? reason = null) - : base(reason ?? "Additional authentication is required to perform this operation.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs index f7bbf0ee..783b3125 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthAuthorizationException : UAuthException { - public sealed class UAuthAuthorizationException : UAuthException + public UAuthAuthorizationException(string? reason = null) + : base(reason ?? "The current principal is not authorized to perform this operation.") { - public UAuthAuthorizationException(string? reason = null) - : base(reason ?? "The current principal is not authorized to perform this operation.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs index a62b507c..fee2bb4a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs @@ -1,17 +1,13 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public abstract class UAuthChainException : UAuthDomainException { - public abstract class UAuthChainException : UAuthDomainException - { - public SessionChainId ChainId { get; } + public SessionChainId ChainId { get; } - protected UAuthChainException( - SessionChainId chainId, - string message) - : base(message) - { - ChainId = chainId; - } + protected UAuthChainException(SessionChainId chainId, string message) : base(message) + { + ChainId = chainId; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs index 39b340d4..bc55cadd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs @@ -1,18 +1,17 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an exception that indicates a developer integration error +/// rather than a runtime or authentication failure. +/// These errors typically occur when UltimateAuth is misconfigured, +/// required services are not registered, or contracts are violated by the host application. +/// +public abstract class UAuthDeveloperException : UAuthException { /// - /// Represents an exception that indicates a developer integration error - /// rather than a runtime or authentication failure. - /// These errors typically occur when UltimateAuth is misconfigured, - /// required services are not registered, or contracts are violated by the host application. + /// Initializes a new instance of the class + /// with a specified error message describing the developer mistake. /// - public abstract class UAuthDeveloperException : UAuthException - { - /// - /// Initializes a new instance of the class - /// with a specified error message describing the developer mistake. - /// - /// The error message explaining the incorrect usage. - protected UAuthDeveloperException(string message) : base(message) { } - } + /// The error message explaining the incorrect usage. + protected UAuthDeveloperException(string message) : base(message) { } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs index 4894aabf..7a19e378 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs @@ -1,16 +1,15 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an exception triggered by a violation of UltimateAuth's domain rules or invariants. +/// These errors indicate that a business rule or authentication domain constraint has been broken (e.g., invalid session state transition, +/// illegal revoke action, or inconsistent security version). +/// +public abstract class UAuthDomainException : UAuthException { /// - /// Represents an exception triggered by a violation of UltimateAuth's domain rules or invariants. - /// These errors indicate that a business rule or authentication domain constraint has been broken (e.g., invalid session state transition, - /// illegal revoke action, or inconsistent security version). + /// Initializes a new instance of the class with a message describing the violated domain rule. /// - public abstract class UAuthDomainException : UAuthException - { - /// - /// Initializes a new instance of the class with a message describing the violated domain rule. - /// - /// The descriptive message for the domain error. - protected UAuthDomainException(string message) : base(message) { } - } + /// The descriptive message for the domain error. + protected UAuthDomainException(string message) : base(message) { } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs index 08ea3e15..13662dc2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs @@ -1,25 +1,24 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents the base type for all exceptions thrown by the UltimateAuth framework. +/// This class differentiates authentication-domain errors from general system exceptions +/// and provides a common abstraction for developer, domain, and runtime error types. +/// +public abstract class UAuthException : Exception { /// - /// Represents the base type for all exceptions thrown by the UltimateAuth framework. - /// This class differentiates authentication-domain errors from general system exceptions - /// and provides a common abstraction for developer, domain, and runtime error types. + /// Initializes a new instance of the class + /// with the specified error message. /// - public abstract class UAuthException : Exception - { - /// - /// Initializes a new instance of the class - /// with the specified error message. - /// - /// The message that describes the error. - protected UAuthException(string message) : base(message) { } + /// The message that describes the error. + protected UAuthException(string message) : base(message) { } - /// - /// Initializes a new instance of the class - /// with the specified error message and underlying exception. - /// - /// The message that describes the error. - /// The exception that caused the current error. - protected UAuthException(string message, Exception? inner) : base(message, inner) { } - } + /// + /// Initializes a new instance of the class + /// with the specified error message and underlying exception. + /// + /// The message that describes the error. + /// The exception that caused the current error. + protected UAuthException(string message, Exception? inner) : base(message, inner) { } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs index 7836b57b..a27d9b9f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs @@ -1,29 +1,27 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents a domain-level exception associated with a specific authentication session. +/// This error indicates that a session-related invariant or rule has been violated, +/// such as attempting to refresh a revoked session, using an expired session, +/// or performing an operation that conflicts with the session's current state. +/// +public abstract class UAuthSessionException : UAuthDomainException { /// - /// Represents a domain-level exception associated with a specific authentication session. - /// This error indicates that a session-related invariant or rule has been violated, - /// such as attempting to refresh a revoked session, using an expired session, - /// or performing an operation that conflicts with the session's current state. + /// Gets the identifier of the session that triggered the exception. /// - public abstract class UAuthSessionException : UAuthDomainException - { - /// - /// Gets the identifier of the session that triggered the exception. - /// - public AuthSessionId SessionId { get; } + public AuthSessionId SessionId { get; } - /// - /// Initializes a new instance of the class with the session identifier and an explanatory error message. - /// - /// The session identifier associated with the error. - /// The message describing the session rule violation. - protected UAuthSessionException(AuthSessionId sessionId, string message) : base(message) - { - SessionId = sessionId; - } + /// + /// Initializes a new instance of the class with the session identifier and an explanatory error message. + /// + /// The session identifier associated with the error. + /// The message describing the session rule violation. + protected UAuthSessionException(AuthSessionId sessionId, string message) : base(message) + { + SessionId = sessionId; } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs index d247a6b8..8710b51e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs @@ -1,18 +1,17 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an exception that is thrown when UltimateAuth is configured +/// incorrectly or when required configuration values are missing or invalid. +/// This error indicates a developer-side setup issue rather than a runtime +/// authentication failure. +/// +public sealed class UAuthConfigException : UAuthDeveloperException { /// - /// Represents an exception that is thrown when UltimateAuth is configured - /// incorrectly or when required configuration values are missing or invalid. - /// This error indicates a developer-side setup issue rather than a runtime - /// authentication failure. + /// Initializes a new instance of the class + /// with a descriptive message explaining the configuration problem. /// - public sealed class UAuthConfigException : UAuthDeveloperException - { - /// - /// Initializes a new instance of the class - /// with a descriptive message explaining the configuration problem. - /// - /// The message describing the configuration error. - public UAuthConfigException(string message) : base(message) { } - } + /// The message describing the configuration error. + public UAuthConfigException(string message) : base(message) { } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs index ad6678a3..703b8acb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs @@ -1,20 +1,19 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an unexpected internal error within the UltimateAuth framework. +/// This exception indicates a failure in internal logic, invariants, or service +/// coordination, rather than a configuration or authentication mistake by the developer. +/// +/// If this exception occurs, it typically means a bug or unhandled scenario +/// exists inside the framework itself. +/// +public sealed class UAuthInternalException : UAuthDeveloperException { /// - /// Represents an unexpected internal error within the UltimateAuth framework. - /// This exception indicates a failure in internal logic, invariants, or service - /// coordination, rather than a configuration or authentication mistake by the developer. - /// - /// If this exception occurs, it typically means a bug or unhandled scenario - /// exists inside the framework itself. + /// Initializes a new instance of the class + /// with a descriptive message explaining the internal framework error. /// - public sealed class UAuthInternalException : UAuthDeveloperException - { - /// - /// Initializes a new instance of the class - /// with a descriptive message explaining the internal framework error. - /// - /// The internal error message. - public UAuthInternalException(string message) : base(message) { } - } + /// The internal error message. + public UAuthInternalException(string message) : base(message) { } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs index 13465d93..0d5eae3d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs @@ -1,18 +1,17 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an exception that occurs when a session or user store +/// behaves incorrectly or violates the UltimateAuth storage contract. +/// This typically indicates an implementation error in the application's +/// persistence layer rather than a framework or authentication issue. +/// +public sealed class UAuthStoreException : UAuthDeveloperException { /// - /// Represents an exception that occurs when a session or user store - /// behaves incorrectly or violates the UltimateAuth storage contract. - /// This typically indicates an implementation error in the application's - /// persistence layer rather than a framework or authentication issue. + /// Initializes a new instance of the class + /// with a descriptive message explaining the store failure. /// - public sealed class UAuthStoreException : UAuthDeveloperException - { - /// - /// Initializes a new instance of the class - /// with a descriptive message explaining the store failure. - /// - /// The message describing the store-related error. - public UAuthStoreException(string message) : base(message) { } - } + /// The message describing the store-related error. + public UAuthStoreException(string message) : base(message) { } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs index 68ce8bd5..2294ae56 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs @@ -1,14 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionChainLinkMissingException : UAuthSessionException { - public sealed class UAuthSessionChainLinkMissingException : UAuthSessionException + public UAuthSessionChainLinkMissingException(AuthSessionId sessionId) + : base(sessionId, $"Session '{sessionId}' is not associated with any session chain.") { - public UAuthSessionChainLinkMissingException(AuthSessionId sessionId) - : base( - sessionId, - $"Session '{sessionId}' is not associated with any session chain.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs index 91d0bafa..40a9b8d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs @@ -1,12 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionChainNotFoundException : UAuthChainException { - public sealed class UAuthSessionChainNotFoundException : UAuthChainException + public UAuthSessionChainNotFoundException(SessionChainId chainId) + : base(chainId, $"Session chain '{chainId}' was not found.") { - public UAuthSessionChainNotFoundException(SessionChainId chainId) - : base(chainId, $"Session chain '{chainId}' was not found.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs index bd880af9..bc08a223 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs @@ -1,14 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionChainRevokedException : UAuthChainException { - public sealed class UAuthSessionChainRevokedException : UAuthChainException + public UAuthSessionChainRevokedException(SessionChainId chainId) + : base(chainId, $"Session chain '{chainId}' has been revoked.") { - public SessionChainId ChainId { get; } - - public UAuthSessionChainRevokedException(SessionChainId chainId) - : base(chainId, $"Session chain '{chainId}' has been revoked.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs index 07e425e1..f6435ad8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs @@ -1,23 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionDeviceMismatchException : UAuthSessionException { - public sealed class UAuthSessionDeviceMismatchException : UAuthSessionException - { - public DeviceInfo Expected { get; } - public DeviceInfo Actual { get; } + public DeviceContext Expected { get; } + public DeviceContext Actual { get; } - public UAuthSessionDeviceMismatchException( - AuthSessionId sessionId, - DeviceInfo expected, - DeviceInfo actual) - : base( - sessionId, - $"Session '{sessionId}' device mismatch detected.") - { - Expected = expected; - Actual = actual; - } + public UAuthSessionDeviceMismatchException(AuthSessionId sessionId, DeviceContext expected, DeviceContext actual) + : base(sessionId, $"Session '{sessionId}' device mismatch detected.") + { + Expected = expected; + Actual = actual; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs index f84fa777..aa7277ad 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs @@ -1,26 +1,25 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an authentication-domain exception thrown when a session +/// has passed its expiration time. +/// +/// This exception is raised during validation or refresh attempts where +/// the session's timestamp +/// indicates that it is no longer valid. +/// +/// Once expired, a session cannot be refreshed — the user must log in again. +/// +public sealed class UAuthSessionExpiredException : UAuthSessionException { /// - /// Represents an authentication-domain exception thrown when a session - /// has passed its expiration time. - /// - /// This exception is raised during validation or refresh attempts where - /// the session's timestamp - /// indicates that it is no longer valid. - /// - /// Once expired, a session cannot be refreshed — the user must log in again. + /// Initializes a new instance of the class + /// using the expired session's identifier. /// - public sealed class UAuthSessionExpiredException : UAuthSessionException + /// The identifier of the expired session. + public UAuthSessionExpiredException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' has expired.") { - /// - /// Initializes a new instance of the class - /// using the expired session's identifier. - /// - /// The identifier of the expired session. - public UAuthSessionExpiredException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' has expired.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs index bd396bd3..64d5b793 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs @@ -1,19 +1,14 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionInvalidStateException : UAuthSessionException { - public sealed class UAuthSessionInvalidStateException : UAuthSessionException - { - public SessionState State { get; } + public SessionState State { get; } - public UAuthSessionInvalidStateException( - AuthSessionId sessionId, - SessionState state) - : base( - sessionId, - $"Session '{sessionId}' is in invalid state '{state}'.") - { - State = state; - } + public UAuthSessionInvalidStateException(AuthSessionId sessionId, SessionState state) + : base(sessionId, $"Session '{sessionId}' is in invalid state '{state}'.") + { + State = state; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs index 21f5aae7..a336dd17 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs @@ -1,25 +1,24 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an authentication-domain exception thrown when a session exists +/// but is not in the state. +/// This exception typically occurs during validation or refresh operations when: +/// - the session is revoked, +/// - the session has expired, +/// - the session belongs to a revoked chain, +/// - or the session is otherwise considered inactive by the runtime state machine. +/// Only active sessions are eligible for refresh and token issuance. +/// +public sealed class UAuthSessionNotActiveException : UAuthSessionException { /// - /// Represents an authentication-domain exception thrown when a session exists - /// but is not in the state. - /// This exception typically occurs during validation or refresh operations when: - /// - the session is revoked, - /// - the session has expired, - /// - the session belongs to a revoked chain, - /// - or the session is otherwise considered inactive by the runtime state machine. - /// Only active sessions are eligible for refresh and token issuance. + /// Initializes a new instance of the class. /// - public sealed class UAuthSessionNotActiveException : UAuthSessionException + /// The identifier of the session that is not active. + public UAuthSessionNotActiveException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' is not active.") { - /// - /// Initializes a new instance of the class. - /// - /// The identifier of the session that is not active. - public UAuthSessionNotActiveException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' is not active.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs index 8cc0a593..b7f32d8f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs @@ -1,13 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionNotFoundException : UAuthSessionException { - public sealed class UAuthSessionNotFoundException - : UAuthSessionException + public UAuthSessionNotFoundException(AuthSessionId sessionId) + : base(sessionId, $"Session '{sessionId}' was not found.") { - public UAuthSessionNotFoundException(AuthSessionId sessionId) - : base(sessionId, $"Session '{sessionId}' was not found.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs index 7d7660c3..1bc96c93 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs @@ -1,27 +1,26 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an authentication-domain exception thrown when an operation attempts +/// to use a session that has been explicitly revoked by the user, administrator, +/// or by system-driven security policies. +/// +/// A revoked session is permanently invalid and cannot be refreshed, validated, +/// or used to obtain new tokens. Revocation typically occurs during actions such as +/// logout, device removal, or administrative account lockdown. +/// +/// This exception is raised in scenarios where a caller assumes the session is active +/// but the underlying session state indicates . +/// +public sealed class UAuthSessionRevokedException : UAuthSessionException { /// - /// Represents an authentication-domain exception thrown when an operation attempts - /// to use a session that has been explicitly revoked by the user, administrator, - /// or by system-driven security policies. - /// - /// A revoked session is permanently invalid and cannot be refreshed, validated, - /// or used to obtain new tokens. Revocation typically occurs during actions such as - /// logout, device removal, or administrative account lockdown. - /// - /// This exception is raised in scenarios where a caller assumes the session is active - /// but the underlying session state indicates . + /// Initializes a new instance of the class. /// - public sealed class UAuthSessionRevokedException : UAuthSessionException + /// The identifier of the revoked session. + public UAuthSessionRevokedException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' has been revoked.") { - /// - /// Initializes a new instance of the class. - /// - /// The identifier of the revoked session. - public UAuthSessionRevokedException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' has been revoked.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs index f1c89786..985eaef7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs @@ -1,13 +1,12 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionRootRevokedException : Exception { - public sealed class UAuthSessionRootRevokedException : Exception - { - public object UserId { get; } + public object UserId { get; } - public UAuthSessionRootRevokedException(object userId) - : base("All sessions for the user have been revoked.") - { - UserId = userId; - } + public UAuthSessionRootRevokedException(object userId) + : base("All sessions for the user have been revoked.") + { + UserId = userId; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs index 9ba17f19..657fc543 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Core.Errors; public sealed class UAuthSessionSecurityMismatchException : UAuthSessionException { diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs index 16152021..c4aaa375 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs @@ -1,25 +1,24 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents a domain-level exception that is thrown when a user exceeds the allowed number of device or platform-specific session chains. +/// This typically occurs when UltimateAuth's session policy restricts the +/// number of concurrent logins for a given platform (e.g., web, mobile) +/// and the user attempts to create an additional session beyond the limit. +/// +public sealed class UAuthDeviceLimitException : UAuthDomainException { /// - /// Represents a domain-level exception that is thrown when a user exceeds the allowed number of device or platform-specific session chains. - /// This typically occurs when UltimateAuth's session policy restricts the - /// number of concurrent logins for a given platform (e.g., web, mobile) - /// and the user attempts to create an additional session beyond the limit. + /// Gets the platform for which the device or session-chain limit was exceeded. /// - public sealed class UAuthDeviceLimitException : UAuthDomainException - { - /// - /// Gets the platform for which the device or session-chain limit was exceeded. - /// - public string Platform { get; } + public string Platform { get; } - /// - /// Initializes a new instance of the class with the specified platform name. - /// - /// The platform on which the limit was exceeded. - public UAuthDeviceLimitException(string platform) : base($"Device limit exceeded for platform '{platform}'.") - { - Platform = platform; - } + /// + /// Initializes a new instance of the class with the specified platform name. + /// + /// The platform on which the limit was exceeded. + public UAuthDeviceLimitException(string platform) : base($"Device limit exceeded for platform '{platform}'.") + { + Platform = platform; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs index 05941d5d..8e573a85 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs @@ -1,18 +1,17 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an authentication failure caused by invalid user credentials. +/// This error is thrown when the supplied username, password, or login +/// identifier does not match any valid user account. +/// +public sealed class UAuthInvalidCredentialsException : UAuthDomainException { /// - /// Represents an authentication failure caused by invalid user credentials. - /// This error is thrown when the supplied username, password, or login - /// identifier does not match any valid user account. + /// Initializes a new instance of the class + /// with a default message indicating incorrect credentials. /// - public sealed class UAuthInvalidCredentialsException : UAuthDomainException + public UAuthInvalidCredentialsException() : base("Invalid username or password.") { - /// - /// Initializes a new instance of the class - /// with a default message indicating incorrect credentials. - /// - public UAuthInvalidCredentialsException() : base("Invalid username or password.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs index 51905d1d..574d269b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs @@ -1,20 +1,19 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an authentication failure occurring during the PKCE authorization +/// flow when the supplied authorization code is invalid, expired, or does not +/// match the original code challenge. +/// This exception indicates a failed PKCE verification rather than a general +/// credential or configuration error. +/// +public sealed class UAuthInvalidPkceCodeException : UAuthDomainException { /// - /// Represents an authentication failure occurring during the PKCE authorization - /// flow when the supplied authorization code is invalid, expired, or does not - /// match the original code challenge. - /// This exception indicates a failed PKCE verification rather than a general - /// credential or configuration error. + /// Initializes a new instance of the class + /// with a default message indicating an invalid PKCE authorization code. /// - public sealed class UAuthInvalidPkceCodeException : UAuthDomainException + public UAuthInvalidPkceCodeException() : base("Invalid PKCE authorization code.") { - /// - /// Initializes a new instance of the class - /// with a default message indicating an invalid PKCE authorization code. - /// - public UAuthInvalidPkceCodeException() : base("Invalid PKCE authorization code.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs index fc1ad258..b0426bd4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs @@ -1,20 +1,19 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents a domain-level authentication failure indicating that the user's +/// entire session root has been revoked. +/// When a root is revoked, all session chains and all sessions belonging to the +/// user become immediately invalid, regardless of their individual expiration +/// or revocation state. +/// +public sealed class UAuthRootRevokedException : UAuthDomainException { /// - /// Represents a domain-level authentication failure indicating that the user's - /// entire session root has been revoked. - /// When a root is revoked, all session chains and all sessions belonging to the - /// user become immediately invalid, regardless of their individual expiration - /// or revocation state. + /// Initializes a new instance of the class + /// with a default message indicating that all sessions under the root are invalid. /// - public sealed class UAuthRootRevokedException : UAuthDomainException + public UAuthRootRevokedException() : base("User root has been revoked. All sessions are invalid.") { - /// - /// Initializes a new instance of the class - /// with a default message indicating that all sessions under the root are invalid. - /// - public UAuthRootRevokedException() : base("User root has been revoked. All sessions are invalid.") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs index 0164df5f..66214ab2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs @@ -1,26 +1,25 @@ -namespace CodeBeam.UltimateAuth.Core.Errors +namespace CodeBeam.UltimateAuth.Core.Errors; + +/// +/// Represents an authentication-domain exception thrown when a token fails its +/// integrity verification checks, indicating that the token may have been altered, +/// corrupted, or tampered with after issuance. +/// +/// This exception is raised during token validation when signature verification fails, +/// claims are inconsistent, or protected fields do not match their expected values. +/// Such failures generally imply either client-side manipulation or +/// man-in-the-middle interference. +/// +/// Applications catching this exception should treat the associated token as unsafe +/// and deny access immediately. Reauthentication or complete session invalidation +/// may be required depending on the security policy. +/// +public sealed class UAuthTokenTamperedException : UAuthDomainException { /// - /// Represents an authentication-domain exception thrown when a token fails its - /// integrity verification checks, indicating that the token may have been altered, - /// corrupted, or tampered with after issuance. - /// - /// This exception is raised during token validation when signature verification fails, - /// claims are inconsistent, or protected fields do not match their expected values. - /// Such failures generally imply either client-side manipulation or - /// man-in-the-middle interference. - /// - /// Applications catching this exception should treat the associated token as unsafe - /// and deny access immediately. Reauthentication or complete session invalidation - /// may be required depending on the security policy. + /// Initializes a new instance of the class. /// - public sealed class UAuthTokenTamperedException : UAuthDomainException + public UAuthTokenTamperedException() : base("Token integrity check failed (possible tampering).") { - /// - /// Initializes a new instance of the class. - /// - public UAuthTokenTamperedException() : base("Token integrity check failed (possible tampering).") - { - } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs index 2c786396..93713baa 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Events -{ - /// - /// Marker interface for all UltimateAuth event context types. - /// - public interface IAuthEventContext { } -} +namespace CodeBeam.UltimateAuth.Core.Events; + +/// +/// Marker interface for all UltimateAuth event context types. +/// +public interface IAuthEventContext { } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs index 544b2eba..341acbdc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs @@ -1,48 +1,47 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Events +namespace CodeBeam.UltimateAuth.Core.Events; + +/// +/// Represents contextual data emitted when a new authentication session is created. +/// +/// This event is published immediately after a successful login or initial session +/// creation within a session chain. It provides the essential identifiers required +/// for auditing, monitoring, analytics, and external integrations. +/// +/// Handlers should treat this event as notification-only; modifying session state +/// or performing security-critical actions is not recommended unless explicitly intended. +/// +public sealed class SessionCreatedContext : IAuthEventContext { /// - /// Represents contextual data emitted when a new authentication session is created. - /// - /// This event is published immediately after a successful login or initial session - /// creation within a session chain. It provides the essential identifiers required - /// for auditing, monitoring, analytics, and external integrations. - /// - /// Handlers should treat this event as notification-only; modifying session state - /// or performing security-critical actions is not recommended unless explicitly intended. + /// Gets the identifier of the user for whom the new session was created. /// - public sealed class SessionCreatedContext : IAuthEventContext - { - /// - /// Gets the identifier of the user for whom the new session was created. - /// - public TUserId UserId { get; } + public TUserId UserId { get; } - /// - /// Gets the unique identifier of the newly created session. - /// - public AuthSessionId SessionId { get; } + /// + /// Gets the unique identifier of the newly created session. + /// + public AuthSessionId SessionId { get; } - /// - /// Gets the identifier of the session chain to which this session belongs. - /// - public SessionChainId ChainId { get; } + /// + /// Gets the identifier of the session chain to which this session belongs. + /// + public SessionChainId ChainId { get; } - /// - /// Gets the timestamp on which the session was created. - /// - public DateTimeOffset CreatedAt { get; } + /// + /// Gets the timestamp on which the session was created. + /// + public DateTimeOffset CreatedAt { get; } - /// - /// Initializes a new instance of the class. - /// - public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, SessionChainId chainId, DateTimeOffset createdAt) - { - UserId = userId; - SessionId = sessionId; - ChainId = chainId; - CreatedAt = createdAt; - } + /// + /// Initializes a new instance of the class. + /// + public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, SessionChainId chainId, DateTimeOffset createdAt) + { + UserId = userId; + SessionId = sessionId; + ChainId = chainId; + CreatedAt = createdAt; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs index 34720489..d3d4b0f3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs @@ -1,60 +1,59 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Events +namespace CodeBeam.UltimateAuth.Core.Events; + +/// +/// Represents contextual data emitted when an authentication session is refreshed. +/// +/// This event occurs whenever a valid session performs a rotation — typically during +/// a refresh-token exchange or session renewal flow. The old session becomes inactive, +/// and a new session inherits updated expiration and security metadata. +/// +/// This event is primarily used for analytics, auditing, security monitoring, and +/// external workflow triggers (e.g., notifying users of new logins, updating dashboards, +/// or tracking device activity). +/// +public sealed class SessionRefreshedContext : IAuthEventContext { /// - /// Represents contextual data emitted when an authentication session is refreshed. - /// - /// This event occurs whenever a valid session performs a rotation — typically during - /// a refresh-token exchange or session renewal flow. The old session becomes inactive, - /// and a new session inherits updated expiration and security metadata. - /// - /// This event is primarily used for analytics, auditing, security monitoring, and - /// external workflow triggers (e.g., notifying users of new logins, updating dashboards, - /// or tracking device activity). + /// Gets the identifier of the user whose session was refreshed. /// - public sealed class SessionRefreshedContext : IAuthEventContext - { - /// - /// Gets the identifier of the user whose session was refreshed. - /// - public TUserId UserId { get; } + public TUserId UserId { get; } - /// - /// Gets the identifier of the session that was replaced during the refresh operation. - /// - public AuthSessionId OldSessionId { get; } + /// + /// Gets the identifier of the session that was replaced during the refresh operation. + /// + public AuthSessionId OldSessionId { get; } - /// - /// Gets the identifier of the newly created session that replaces the old session. - /// - public AuthSessionId NewSessionId { get; } + /// + /// Gets the identifier of the newly created session that replaces the old session. + /// + public AuthSessionId NewSessionId { get; } - /// - /// Gets the identifier of the session chain to which both sessions belong. - /// - public SessionChainId ChainId { get; } + /// + /// Gets the identifier of the session chain to which both sessions belong. + /// + public SessionChainId ChainId { get; } - /// - /// Gets the timestamp at which the refresh occurred. - /// - public DateTimeOffset RefreshedAt { get; } + /// + /// Gets the timestamp at which the refresh occurred. + /// + public DateTimeOffset RefreshedAt { get; } - /// - /// Initializes a new instance of the class. - /// - public SessionRefreshedContext( - TUserId userId, - AuthSessionId oldSessionId, - AuthSessionId newSessionId, - SessionChainId chainId, - DateTimeOffset refreshedAt) - { - UserId = userId; - OldSessionId = oldSessionId; - NewSessionId = newSessionId; - ChainId = chainId; - RefreshedAt = refreshedAt; - } + /// + /// Initializes a new instance of the class. + /// + public SessionRefreshedContext( + TUserId userId, + AuthSessionId oldSessionId, + AuthSessionId newSessionId, + SessionChainId chainId, + DateTimeOffset refreshedAt) + { + UserId = userId; + OldSessionId = oldSessionId; + NewSessionId = newSessionId; + ChainId = chainId; + RefreshedAt = refreshedAt; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs index fc5167ab..04f0040d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs @@ -1,57 +1,55 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Events +namespace CodeBeam.UltimateAuth.Core.Events; + +/// +/// Represents contextual data emitted when an individual session is revoked. +/// +/// This event is triggered when a specific session is invalidated — either due to +/// explicit logout, administrator action, security enforcement, or anomaly detection. +/// Only the targeted session is revoked; other sessions in the same chain or root +/// may continue to remain active unless broader revocation policies apply. +/// +/// Typical use cases include: +/// - Auditing and compliance logs +/// - User notifications (e.g., “Your session on device X was logged out”) +/// - Security automations (SIEM integration, monitoring suspicious activity) +/// - Application workflows that must respond to session termination +/// +public sealed class SessionRevokedContext : IAuthEventContext { /// - /// Represents contextual data emitted when an individual session is revoked. - /// - /// This event is triggered when a specific session is invalidated — either due to - /// explicit logout, administrator action, security enforcement, or anomaly detection. - /// Only the targeted session is revoked; other sessions in the same chain or root - /// may continue to remain active unless broader revocation policies apply. - /// - /// Typical use cases include: - /// - Auditing and compliance logs - /// - User notifications (e.g., “Your session on device X was logged out”) - /// - Security automations (SIEM integration, monitoring suspicious activity) - /// - Application workflows that must respond to session termination + /// Gets the identifier of the user to whom the revoked session belongs. /// - public sealed class SessionRevokedContext : IAuthEventContext - { - /// - /// Gets the identifier of the user to whom the revoked session belongs. - /// - public TUserId UserId { get; } + public TUserId UserId { get; } - /// - /// Gets the identifier of the session that has been revoked. - /// - public AuthSessionId SessionId { get; } + /// + /// Gets the identifier of the session that has been revoked. + /// + public AuthSessionId SessionId { get; } - /// - /// Gets the identifier of the session chain containing the revoked session. - /// - public SessionChainId ChainId { get; } + /// + /// Gets the identifier of the session chain containing the revoked session. + /// + public SessionChainId ChainId { get; } - /// - /// Gets the timestamp at which the session revocation occurred. - /// - public DateTimeOffset RevokedAt { get; } + /// + /// Gets the timestamp at which the session revocation occurred. + /// + public DateTimeOffset RevokedAt { get; } - /// - /// Initializes a new instance of the class. - /// - public SessionRevokedContext( - TUserId userId, - AuthSessionId sessionId, - SessionChainId chainId, - DateTimeOffset revokedAt) - { - UserId = userId; - SessionId = sessionId; - ChainId = chainId; - RevokedAt = revokedAt; - } + /// + /// Initializes a new instance of the class. + /// + public SessionRevokedContext( + TUserId userId, + AuthSessionId sessionId, + SessionChainId chainId, + DateTimeOffset revokedAt) + { + UserId = userId; + SessionId = sessionId; + ChainId = chainId; + RevokedAt = revokedAt; } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs index 55522192..c7d08746 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs @@ -1,53 +1,52 @@ -namespace CodeBeam.UltimateAuth.Core.Events +namespace CodeBeam.UltimateAuth.Core.Events; + +internal sealed class UAuthEventDispatcher { - internal sealed class UAuthEventDispatcher - { - private readonly UAuthEvents _events; + private readonly UAuthEvents _events; - public UAuthEventDispatcher(UAuthEvents events) - { - _events = 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 SessionCreatedContext c: - if (_events.OnSessionCreated != null) - await SafeInvoke(() => _events.OnSessionCreated(c)); - break; - - case SessionRefreshedContext c: - if (_events.OnSessionRefreshed != null) - await SafeInvoke(() => _events.OnSessionRefreshed(c)); - break; - - case SessionRevokedContext c: - if (_events.OnSessionRevoked != null) - await SafeInvoke(() => _events.OnSessionRevoked(c)); - break; - - 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; - } - } + public async Task DispatchAsync(IAuthEventContext context) + { + if (_events.OnAnyEvent is not null) + await SafeInvoke(() => _events.OnAnyEvent(context)); - private static async Task SafeInvoke(Func func) + switch (context) { - try { await func(); } - catch { /* swallow → event hook must not break auth flow */ } + case SessionCreatedContext c: + if (_events.OnSessionCreated != null) + await SafeInvoke(() => _events.OnSessionCreated(c)); + break; + + case SessionRefreshedContext c: + if (_events.OnSessionRefreshed != null) + await SafeInvoke(() => _events.OnSessionRefreshed(c)); + break; + + case SessionRevokedContext c: + if (_events.OnSessionRevoked != null) + await SafeInvoke(() => _events.OnSessionRevoked(c)); + break; + + 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 index 5d67cd5a..fadd610d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs @@ -1,57 +1,56 @@ -namespace CodeBeam.UltimateAuth.Core.Events +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 { /// - /// 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. + /// Fired on every auth-related event. + /// This global hook allows logging, tracing or metrics pipelines to observe all events. /// - 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; } + public Func? OnAnyEvent { get; set; } - /// - /// Fired when a new session is created (login or device bootstrap). - /// - public Func, Task>? OnSessionCreated { get; set; } + /// + /// Fired when a new session is created (login or device bootstrap). + /// + public Func, Task>? OnSessionCreated { get; set; } - /// - /// Fired when an existing session is refreshed and rotated. - /// - public Func, Task>? OnSessionRefreshed { get; set; } + /// + /// Fired when an existing session is refreshed and rotated. + /// + public Func, Task>? OnSessionRefreshed { get; set; } - /// - /// Fired when a specific session is revoked. - /// - public Func, Task>? OnSessionRevoked { get; set; } + /// + /// Fired when a specific session is revoked. + /// + public Func, Task>? OnSessionRevoked { get; set; } - /// - /// Fired when a user successfully completes the login process. - /// Note: separate from SessionCreated; this is a higher-level event. - /// - public Func, Task>? OnUserLoggedIn { get; set; } + /// + /// Fired when a user successfully completes the login process. + /// Note: separate from SessionCreated; this is a higher-level event. + /// + public Func, Task>? OnUserLoggedIn { get; set; } - /// - /// Fired when a user logs out or all sessions for the user are revoked. - /// - public Func, Task>? OnUserLoggedOut { get; set; } - } + /// + /// Fired when a user logs out or all sessions for the user are revoked. + /// + public Func, Task>? OnUserLoggedOut { get; set; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs index b661db79..54ef844b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs @@ -1,42 +1,41 @@ -namespace CodeBeam.UltimateAuth.Core.Events +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 . +/// 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 { /// - /// 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 . - /// 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. + /// Gets the identifier of the user who has logged in. /// - public sealed class UserLoggedInContext : IAuthEventContext - { - /// - /// Gets the identifier of the user who has logged in. - /// - public TUserId UserId { get; } + public TUserId UserId { get; } - /// - /// Gets the timestamp at which the login event occurred. - /// - public DateTimeOffset LoggedInAt { get; } + /// + /// Gets the timestamp at which the login event occurred. + /// + public DateTimeOffset LoggedInAt { get; } - /// - /// Initializes a new instance of the class. - /// - public UserLoggedInContext(TUserId userId, DateTimeOffset at) - { - UserId = userId; - LoggedInAt = at; - } + /// + /// Initializes a new instance of the class. + /// + public UserLoggedInContext(TUserId userId, DateTimeOffset at) + { + UserId = userId; + LoggedInAt = at; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs index 6f6e707f..85278192 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs @@ -1,41 +1,40 @@ -namespace CodeBeam.UltimateAuth.Core.Events +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 , 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 { /// - /// 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 , 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 + /// Gets the identifier of the user who has logged out. /// - public sealed class UserLoggedOutContext : IAuthEventContext - { - /// - /// Gets the identifier of the user who has logged out. - /// - public TUserId UserId { get; } + public TUserId UserId { get; } - /// - /// Gets the timestamp at which the logout occurred. - /// - public DateTimeOffset LoggedOutAt { get; } + /// + /// Gets the timestamp at which the logout occurred. + /// + public DateTimeOffset LoggedOutAt { get; } - /// - /// Initializes a new instance of the class. - /// - public UserLoggedOutContext(TUserId userId, DateTimeOffset at) - { - UserId = userId; - LoggedOutAt = at; - } + /// + /// Initializes a new instance of the class. + /// + public UserLoggedOutContext(TUserId userId, DateTimeOffset at) + { + UserId = userId; + LoggedOutAt = at; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs index fa7bc2f2..d54703ec 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -1,61 +1,60 @@ using CodeBeam.UltimateAuth.Core.Domain; using System.Security.Claims; -namespace CodeBeam.UltimateAuth.Core.Extensions +namespace CodeBeam.UltimateAuth.Core.Extensions; + +public static class ClaimsSnapshotExtensions { - public static class ClaimsSnapshotExtensions + /// + /// Converts a ClaimsSnapshot into an ASP.NET Core ClaimsPrincipal. + /// + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = "UltimateAuth") { - /// - /// Converts a ClaimsSnapshot into an ASP.NET Core ClaimsPrincipal. - /// - public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = "UltimateAuth") - { - if (snapshot == null) - return new ClaimsPrincipal(new ClaimsIdentity()); + if (snapshot == null) + return new ClaimsPrincipal(new ClaimsIdentity()); - var claims = snapshot.Claims.SelectMany(kv => kv.Value.Select(value => new Claim(kv.Key, value))); + 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); - } + var identity = new ClaimsIdentity(claims, authenticationType); + return new ClaimsPrincipal(identity); + } - /// - /// Converts an ASP.NET Core ClaimsPrincipal into a ClaimsSnapshot. - /// - public static ClaimsSnapshot ToClaimsSnapshot(this ClaimsPrincipal principal) - { - if (principal is null) - return ClaimsSnapshot.Empty; + /// + /// 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; + if (principal.Identity?.IsAuthenticated != true) + return ClaimsSnapshot.Empty; - var dict = new Dictionary>(StringComparer.Ordinal); + var dict = new Dictionary>(StringComparer.Ordinal); - foreach (var claim in principal.Claims) + foreach (var claim in principal.Claims) + { + if (!dict.TryGetValue(claim.Type, out var set)) { - if (!dict.TryGetValue(claim.Type, out var set)) - { - set = new HashSet(StringComparer.Ordinal); - dict[claim.Type] = set; - } - - set.Add(claim.Value); + set = new HashSet(StringComparer.Ordinal); + dict[claim.Type] = set; } - return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + set.Add(claim.Value); } - public static IEnumerable ToClaims(this ClaimsSnapshot snapshot) + 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 (type, values) in snapshot.Claims) + foreach (var value in values) { - foreach (var value in values) - { - yield return new Claim(type, value); - } + 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..17204ca2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,92 @@ +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; + +// TODO: Check it before stable release +/// +/// 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 +{ + /// + /// Registers UltimateAuth services using configuration binding (e.g., appsettings.json). + /// + /// The provided configuration section must contain valid UltimateAuthOptions and nested + /// Session, Token, PKCE, and MultiTenant configuration sections. Validation occurs + /// at application startup via IValidateOptions. + /// + public static IServiceCollection AddUltimateAuth(this IServiceCollection services, IConfiguration configurationSection) + { + services.Configure(configurationSection); + return services.AddUltimateAuthInternal(); + } + + /// + /// Registers UltimateAuth services using programmatic configuration. + /// This is useful when settings are derived dynamically or are not stored + /// in appsettings.json. + /// + public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action configure) + { + services.Configure(configure); + return services.AddUltimateAuthInternal(); + } + + /// + /// Registers UltimateAuth services using default empty configuration. + /// Intended for advanced or fully manual scenarios where options will be + /// configured later or overridden by the server layer. + /// + public static IServiceCollection AddUltimateAuth(this IServiceCollection services) + { + services.Configure(_ => { }); + 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.AddSingleton, UAuthOptionsValidator>(); + services.AddSingleton, UAuthSessionOptionsValidator>(); + services.AddSingleton, UAuthTokenOptionsValidator>(); + services.AddSingleton, UAuthPkceOptionsValidator>(); + services.AddSingleton, UAuthMultiTenantOptionsValidator>(); + + // Nested options are bound automatically by the options binder. + // Server layer may override or extend these settings. + + services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs deleted file mode 100644 index a8a7f035..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs +++ /dev/null @@ -1,95 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -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 -{ - // TODO: Check it before stable release - /// - /// 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 UltimateAuthServiceCollectionExtensions - { - /// - /// Registers UltimateAuth services using configuration binding (e.g., appsettings.json). - /// - /// The provided configuration section must contain valid UltimateAuthOptions and nested - /// Session, Token, PKCE, and MultiTenant configuration sections. Validation occurs - /// at application startup via IValidateOptions. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services, IConfiguration configurationSection) - { - services.Configure(configurationSection); - return services.AddUltimateAuthInternal(); - } - - /// - /// Registers UltimateAuth services using programmatic configuration. - /// This is useful when settings are derived dynamically or are not stored - /// in appsettings.json. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action configure) - { - services.Configure(configure); - return services.AddUltimateAuthInternal(); - } - - /// - /// Registers UltimateAuth services using default empty configuration. - /// Intended for advanced or fully manual scenarios where options will be - /// configured later or overridden by the server layer. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services) - { - services.Configure(_ => { }); - 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.AddSingleton, UAuthOptionsValidator>(); - services.AddSingleton, UAuthSessionOptionsValidator>(); - services.AddSingleton, UAuthTokenOptionsValidator>(); - services.AddSingleton, UAuthPkceOptionsValidator>(); - services.AddSingleton, UAuthMultiTenantOptionsValidator>(); - - // Nested options are bound automatically by the options binder. - // Server layer may override or extend these settings. - - services.AddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - - return services; - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs deleted file mode 100644 index cf96f955..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthSessionStoreExtensions.cs +++ /dev/null @@ -1,102 +0,0 @@ -//using CodeBeam.UltimateAuth.Core.Abstractions; -//using Microsoft.Extensions.DependencyInjection; -//using Microsoft.Extensions.DependencyInjection.Extensions; - -//namespace CodeBeam.UltimateAuth.Core.Extensions -//{ -// /// -// /// Provides extension methods for registering a concrete -// /// implementation into the application's dependency injection container. -// /// -// /// UltimateAuth requires exactly one session store implementation that determines -// /// how sessions, chains, and roots are persisted (e.g., EF Core, Dapper, Redis, MongoDB). -// /// This extension performs automatic generic type resolution and registers the correct -// /// ISessionStore<TUserId> for the application's user ID type. -// /// -// /// The method enforces that the provided store implements ISessionStore'TUserId';. -// /// If the type cannot be determined, an exception is thrown to prevent misconfiguration. -// /// -// public static class UltimateAuthSessionStoreExtensions -// { -// /// -// /// Registers a custom session store implementation for UltimateAuth. -// /// The supplied must implement ISessionStore'TUserId'; -// /// exactly once with a single TUserId generic argument. -// /// -// /// After registration, the internal session store factory resolves the correct -// /// ISessionStore instance at runtime for the active tenant and TUserId type. -// /// -// /// The concrete session store implementation. -// public static IServiceCollection AddUltimateAuthSessionStore(this IServiceCollection services) -// where TStore : class -// { -// var storeInterface = typeof(TStore) -// .GetInterfaces() -// .FirstOrDefault(i => -// i.IsGenericType && -// i.GetGenericTypeDefinition() == typeof(ISessionStoreKernel<>)); - -// if (storeInterface is null) -// { -// throw new InvalidOperationException( -// $"{typeof(TStore).Name} must implement ISessionStoreKernel."); -// } - -// var userIdType = storeInterface.GetGenericArguments()[0]; -// var typedInterface = typeof(ISessionStoreKernel<>).MakeGenericType(userIdType); - -// services.TryAddScoped(typedInterface, typeof(TStore)); - -// services.AddSingleton(sp => -// new GenericSessionStoreFactory(sp, userIdType)); - -// return services; -// } -// } - -// /// -// /// Default session store factory used by UltimateAuth to dynamically create -// /// the correct ISessionStore<TUserId> implementation at runtime. -// /// -// /// This factory ensures type safety by validating the requested TUserId against -// /// the registered session store’s user ID type. Attempting to resolve a mismatched -// /// TUserId results in a descriptive exception to prevent silent misconfiguration. -// /// -// /// Tenant ID is passed through so that multi-tenant implementations can perform -// /// tenant-aware routing, filtering, or partition-based selection. -// /// -// internal sealed class GenericSessionStoreFactory : ISessionStoreFactory -// { -// private readonly IServiceProvider _sp; -// private readonly Type _userIdType; - -// /// -// /// Initializes a new instance of the class. -// /// -// public GenericSessionStoreFactory(IServiceProvider sp, Type userIdType) -// { -// _sp = sp; -// _userIdType = userIdType; -// } - -// /// -// /// Creates and returns the registered ISessionStore<TUserId> implementation -// /// for the specified tenant and user ID type. -// /// Throws if the requested TUserId does not match the registered store's type. -// /// -// public ISessionStoreKernel Create(string? tenantId) -// { -// if (typeof(TUserId) != _userIdType) -// { -// throw new InvalidOperationException( -// $"SessionStore registered for TUserId='{_userIdType.Name}', " + -// $"but requested with TUserId='{typeof(TUserId).Name}'."); -// } - -// var typed = typeof(ISessionStoreKernel<>).MakeGenericType(_userIdType); -// var store = _sp.GetRequiredService(typed); - -// return (ISessionStoreKernel)store; -// } -// } -//} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs index 6c40f103..8c87ae75 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs @@ -1,64 +1,63 @@ using Microsoft.Extensions.DependencyInjection; using CodeBeam.UltimateAuth.Core.Abstractions; -namespace CodeBeam.UltimateAuth.Core.Extensions +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 { /// - /// Provides extension methods for registering custom - /// implementations into the dependency injection container. + /// Registers a custom implementation. /// - /// UltimateAuth internally relies on user ID normalization for: - /// - session store lookups - /// - token generation and validation - /// - logging and diagnostics - /// - multi-tenant user routing + /// 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.). /// - /// 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). + /// 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). /// - public static class UserIdConverterRegistrationExtensions + /// The application's user ID type. + /// The custom converter implementation. + public static IServiceCollection AddUltimateAuthUserIdConverter( + this IServiceCollection services) + where TConverter : class, IUserIdConverter { - /// - /// 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; - } + 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; - } + /// + /// 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/AuthUserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs deleted file mode 100644 index 79885c21..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Infrastructure -{ - /// - /// Represents the minimal, immutable user snapshot required by the UltimateAuth Core - /// during authentication discovery and subject binding. - /// - /// This type is NOT a domain user model. - /// It contains only normalized, opinionless fields that determine whether - /// a user can participate in authentication flows. - /// - /// AuthUserRecord is produced by the Users domain as a boundary projection - /// and is never mutated by the Core. - /// - public sealed record AuthUserRecord - { - /// - /// Application-level user identifier. - /// - public required TUserId Id { get; init; } - - /// - /// Primary login identifier (username, email, etc). - /// Used only for discovery and uniqueness checks. - /// - public required string Identifier { get; init; } - - /// - /// Indicates whether the user is considered active for authentication purposes. - /// Domain-specific statuses are normalized into this flag by the Users domain. - /// - public required bool IsActive { get; init; } - - /// - /// Indicates whether the user is deleted. - /// Deleted users are never eligible for authentication. - /// - public required bool IsDeleted { get; init; } - - /// - /// The timestamp when the user was originally created. - /// Provided for invariant validation and auditing purposes. - /// - public required DateTimeOffset CreatedAt { get; init; } - - /// - /// The timestamp when the user was deleted, if applicable. - /// - public DateTimeOffset? DeletedAt { get; init; } - } -} 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/DefaultAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs deleted file mode 100644 index 1b826920..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs +++ /dev/null @@ -1,50 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Infrastructure -{ - public sealed class DefaultAuthAuthority : IAuthAuthority - { - private readonly IEnumerable _invariants; - private readonly IEnumerable _policies; - - public DefaultAuthAuthority(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/DeviceMismatchPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs index 1d53f385..5de4ac5e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs @@ -1,32 +1,30 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class DeviceMismatchPolicy : IAuthorityPolicy { - public sealed class DeviceMismatchPolicy : IAuthorityPolicy - { - public bool AppliesTo(AuthContext context) - => context.Device is not null; + public bool AppliesTo(AuthContext context) => context.Device is not null; - public AccessDecisionResult Decide(AuthContext context) - { - var device = context.Device; + public AccessDecisionResult Decide(AuthContext context) + { + var device = context.Device; - //if (device.IsKnownDevice) - // return AuthorizationResult.Allow(); + //if (device.IsKnownDevice) + // return AuthorizationResult.Allow(); - return context.Operation switch - { - AuthOperation.Access => - AccessDecisionResult.Deny("Access from unknown device."), + return context.Operation switch + { + AuthOperation.Access => + AccessDecisionResult.Deny("Access from unknown device."), - AuthOperation.Refresh => - AccessDecisionResult.Challenge("Device verification required."), + AuthOperation.Refresh => + AccessDecisionResult.Challenge("Device verification required."), - AuthOperation.Login => AccessDecisionResult.Allow(), // login establishes device + AuthOperation.Login => AccessDecisionResult.Allow(), // login establishes device - _ => AccessDecisionResult.Allow() - }; - } + _ => AccessDecisionResult.Allow() + }; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs index 5bcd7328..b9498b81 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs @@ -1,20 +1,18 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class DevicePresenceInvariant : IAuthorityInvariant { - public sealed class DevicePresenceInvariant : IAuthorityInvariant + public AccessDecisionResult Decide(AuthContext context) { - public AccessDecisionResult Decide(AuthContext context) + if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) { - if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) - { - if (context.Device is null) - return AccessDecisionResult.Deny("Device information is required."); - } - - return AccessDecisionResult.Allow(); + if (context.Device is null) + return AccessDecisionResult.Deny("Device information is required."); } - } + return AccessDecisionResult.Allow(); + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs index cb9e14c6..6ef97ad7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs @@ -2,26 +2,25 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class ExpiredSessionInvariant : IAuthorityInvariant { - public sealed class ExpiredSessionInvariant : IAuthorityInvariant + public AccessDecisionResult Decide(AuthContext context) { - 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 (context.Operation == AuthOperation.Login) + return AccessDecisionResult.Allow(); - if (session.State == SessionState.Expired) - { - return AccessDecisionResult.Deny("Session has expired."); - } + 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 index 7d8fe9a5..0b971799 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs @@ -2,30 +2,29 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class InvalidOrRevokedSessionInvariant : IAuthorityInvariant { - public sealed class InvalidOrRevokedSessionInvariant : IAuthorityInvariant + public AccessDecisionResult Decide(AuthContext context) { - public AccessDecisionResult Decide(AuthContext context) - { - if (context.Operation == AuthOperation.Login) - return AccessDecisionResult.Allow(); - - var session = context.Session; + if (context.Operation == AuthOperation.Login) + return AccessDecisionResult.Allow(); - if (session is null) - return AccessDecisionResult.Deny("Session is required for this operation."); + var session = context.Session; - 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}"); - } + if (session is null) + return AccessDecisionResult.Deny("Session is required for this operation."); - return AccessDecisionResult.Allow(); + 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/UAuthModeOperationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs index 459e4ca8..d4ac9b7e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs @@ -1,39 +1,38 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class AuthModeOperationPolicy : IAuthorityPolicy { - public sealed class AuthModeOperationPolicy : IAuthorityPolicy - { - public bool AppliesTo(AuthContext context) => true; // Applies to all contexts + public bool AppliesTo(AuthContext context) => true; // Applies to all contexts - public AccessDecisionResult Decide(AuthContext context) - { - return context.Mode switch - { - UAuthMode.PureOpaque => DecideForPureOpaque(context), - UAuthMode.PureJwt => DecideForPureJwt(context), - UAuthMode.Hybrid => AccessDecisionResult.Allow(), - UAuthMode.SemiHybrid => AccessDecisionResult.Allow(), - - _ => AccessDecisionResult.Deny("Unsupported authentication mode.") - }; - } - - private static AccessDecisionResult DecideForPureOpaque(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) + { + return context.Mode switch { - if (context.Operation == AuthOperation.Refresh) - return AccessDecisionResult.Deny("Refresh operation is not supported in PureOpaque mode."); + UAuthMode.PureOpaque => DecideForPureOpaque(context), + UAuthMode.PureJwt => DecideForPureJwt(context), + UAuthMode.Hybrid => AccessDecisionResult.Allow(), + UAuthMode.SemiHybrid => AccessDecisionResult.Allow(), - return AccessDecisionResult.Allow(); - } + _ => AccessDecisionResult.Deny("Unsupported authentication mode.") + }; + } - private static AccessDecisionResult DecideForPureJwt(AuthContext context) - { - if (context.Operation == AuthOperation.Access) - return AccessDecisionResult.Deny("Session-based access is not supported in PureJwt mode."); + private static AccessDecisionResult DecideForPureOpaque(AuthContext context) + { + if (context.Operation == AuthOperation.Refresh) + return AccessDecisionResult.Deny("Refresh operation is not supported in PureOpaque mode."); + + return AccessDecisionResult.Allow(); + } + + private static AccessDecisionResult DecideForPureJwt(AuthContext context) + { + if (context.Operation == AuthOperation.Access) + return AccessDecisionResult.Deny("Session-based access is not supported in PureJwt mode."); - return AccessDecisionResult.Allow(); - } + return AccessDecisionResult.Allow(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs index 48fb6c83..14b2de0d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs @@ -1,49 +1,43 @@ -namespace CodeBeam.UltimateAuth.Core.Infrastructure +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 { /// - /// 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. + /// Encodes a byte array into a URL-safe Base64 string by applying + /// RFC 4648 URL-safe transformations and removing padding. /// - public static class Base64Url + /// The binary data to encode. + /// A URL-safe Base64 encoded string. + public static string Encode(byte[] input) { - /// - /// 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("_", "/"); + var base64 = Convert.ToBase64String(input); + return base64.Replace("+", "-").Replace("/", "_").Replace("=", ""); + } - switch (padded.Length % 4) - { - case 2: padded += "=="; break; - case 3: padded += "="; break; - } + /// + /// 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("_", "/"); - return Convert.FromBase64String(padded); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; } + return Convert.FromBase64String(padded); } + } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs index afe906be..b96d09a0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class GuidUserIdFactory : IUserIdFactory { - public sealed class GuidUserIdFactory : IUserIdFactory - { - public Guid Create() => Guid.NewGuid(); - } + public Guid Create() => Guid.NewGuid(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs index 3c61b2fa..57a25023 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public interface IInMemoryUserIdProvider { - public interface IInMemoryUserIdProvider - { - TUserId GetAdminUserId(); - TUserId GetUserUserId(); - } + TUserId GetAdminUserId(); + TUserId GetUserUserId(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs index ebb56c66..18fa200a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs @@ -1,16 +1,16 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +internal sealed class NoopAccessTokenIdStore : IAccessTokenIdStore { - internal sealed class NoopAccessTokenIdStore : IAccessTokenIdStore - { - public Task StoreAsync(string? tenantId, string jti, DateTimeOffset expiresAt, CancellationToken ct = default) - => Task.CompletedTask; + public Task StoreAsync(TenantKey tenant, string jti, DateTimeOffset expiresAt, CancellationToken ct = default) + => Task.CompletedTask; - public Task IsRevokedAsync(string? tenantId, string jti, CancellationToken ct = default) - => Task.FromResult(false); + public Task IsRevokedAsync(TenantKey tenant, string jti, CancellationToken ct = default) + => Task.FromResult(false); - public Task RevokeAsync(string? tenantId, string jti, DateTimeOffset revokedAt, CancellationToken ct = default) - => Task.CompletedTask; - } + public Task RevokeAsync(TenantKey tenant, string jti, DateTimeOffset revokedAt, CancellationToken ct = default) + => Task.CompletedTask; } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs deleted file mode 100644 index b2faa234..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Security.Cryptography; - -namespace CodeBeam.UltimateAuth.Core.Infrastructure -{ - /// - /// Provides cryptographically secure random ID generation. - /// - /// Produces opaque identifiers suitable for session IDs, PKCE codes, - /// refresh tokens, and other entropy-critical values. Output is encoded - /// using Base64Url for safe transport in URLs and headers. - /// - public static class RandomIdGenerator - { - /// - /// Generates a cryptographically secure random identifier with the - /// specified byte length and returns it as a URL-safe Base64 string. - /// - /// The number of random bytes to generate. - /// A URL-safe Base64 encoded random value. - /// - /// Thrown when is zero or negative. - /// - public static string Generate(int byteLength) - { - if (byteLength <= 0) - throw new ArgumentOutOfRangeException(nameof(byteLength)); - - var buffer = new byte[byteLength]; - RandomNumberGenerator.Fill(buffer); - - return Base64Url.Encode(buffer); - } - - /// - /// Generates a cryptographically secure random byte array with the - /// specified length. - /// - /// The number of bytes to generate. - /// A randomly filled byte array. - /// - /// Thrown when is zero or negative. - /// - public static byte[] GenerateBytes(int byteLength) - { - if (byteLength <= 0) - throw new ArgumentOutOfRangeException(nameof(byteLength)); - - var buffer = new byte[byteLength]; - RandomNumberGenerator.Fill(buffer); - return buffer; - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs index d0bf6adf..6ccb72ab 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Infrastructure; @@ -9,18 +10,21 @@ public sealed class SeedRunner public SeedRunner(IEnumerable contributors) { _contributors = contributors; - Console.WriteLine("SeedRunner contributors:"); + foreach (var c in contributors) { Console.WriteLine($"- {c.GetType().FullName}"); } } - public async Task RunAsync(string? tenantId, CancellationToken ct = default) + 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(tenantId, ct); + await c.SeedAsync((TenantKey)tenant, ct); } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs index a622edfa..12c7f209 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class StringUserIdFactory : IUserIdFactory { - public sealed class StringUserIdFactory : IUserIdFactory - { - public string Create() => Guid.NewGuid().ToString("N"); - } + public string Create() => Guid.NewGuid().ToString("N"); } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs similarity index 81% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs index 6f2b08f4..53ca29b6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/DefaultRefreshTokenValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs @@ -3,12 +3,12 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; -public sealed class DefaultRefreshTokenValidator : IRefreshTokenValidator +public sealed class UAuthRefreshTokenValidator : IRefreshTokenValidator { private readonly IRefreshTokenStore _store; private readonly ITokenHasher _hasher; - public DefaultRefreshTokenValidator(IRefreshTokenStore store, ITokenHasher hasher) + public UAuthRefreshTokenValidator(IRefreshTokenStore store, ITokenHasher hasher) { _store = store; _hasher = hasher; @@ -17,21 +17,21 @@ public DefaultRefreshTokenValidator(IRefreshTokenStore store, ITokenHasher hashe public async Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default) { var hash = _hasher.Hash(context.RefreshToken); - var stored = await _store.FindByHashAsync(context.TenantId, hash, ct); + var stored = await _store.FindByHashAsync(context.Tenant, hash, ct); if (stored is null) return RefreshTokenValidationResult.Invalid(); if (stored.IsRevoked) return RefreshTokenValidationResult.ReuseDetected( - tenantId: stored.TenantId, + tenant: stored.Tenant, sessionId: stored.SessionId, chainId: stored.ChainId, userKey: stored.UserKey); if (stored.IsExpired(context.Now)) { - await _store.RevokeAsync(context.TenantId, hash, context.Now, null, ct); + await _store.RevokeAsync(context.Tenant, hash, context.Now, null, ct); return RefreshTokenValidationResult.Invalid(); } @@ -45,7 +45,7 @@ public async Task ValidateAsync(RefreshTokenValida // return Invalid(); return RefreshTokenValidationResult.Valid( - tenantId: stored.TenantId, + tenant: stored.Tenant, stored.UserKey, stored.SessionId, hash, diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs index 44465ac5..083a6555 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs @@ -5,111 +5,110 @@ using System.Text; using System.Text.Json; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +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 { /// - /// 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 + /// Converts the specified user id into a canonical string representation. + /// Primitive types use invariant culture or compact formats; complex objects + /// are serialized via JSON. /// - public sealed class UAuthUserIdConverter : IUserIdConverter + /// The user identifier to convert. + /// A normalized string representation of the user id. + public string ToCanonicalString(TUserId id) { - /// - /// 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 ToString(TUserId id) + return id switch { - 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), + 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.") - }; - } + _ => 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(ToString(id)); + /// + /// 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. - /// - /// Thrown when deserialization of complex types fails. - /// - public TUserId FromString(string value) + /// + /// 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. + /// + /// Thrown when deserialization of complex types fails. + /// + public TUserId FromString(string value) + { + return typeof(TUserId) switch { - 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), + 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 UAuthInternalException("Cannot deserialize TUserId") - }; - } + _ => JsonSerializer.Deserialize(value) + ?? throw new UAuthInternalException("Cannot deserialize TUserId") + }; + } - public bool TryFromString(string value, out TUserId? id) + public bool TryFromString(string value, out TUserId id) + { + try { - try - { - id = FromString(value); - return true; - } - catch - { - id = default; - return false; - } + 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)); + /// + /// 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) + public bool TryFromBytes(byte[] binary, out TUserId id) + { + try { - try - { - id = FromBytes(binary); - return true; - } - catch - { - id = default; - return false; - } + id = FromBytes(binary); + return true; + } + catch + { + id = default!; + return false; } - } + } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs index 0d7a489e..8c4c716f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs @@ -1,47 +1,46 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +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; + /// - /// 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. + /// Initializes a new instance of the class. /// - public sealed class UAuthUserIdConverterResolver : IUserIdConverterResolver + /// The service provider used to resolve converters from DI. + public UAuthUserIdConverterResolver(IServiceProvider sp) { - 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; + _sp = sp; + } - return new UAuthUserIdConverter(); - } + /// + /// 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 index 8872df75..f024b89d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class UserIdFactory : IUserIdFactory { - public sealed class UserIdFactory : IUserIdFactory - { - public UserKey Create() => UserKey.New(); - } + public UserKey Create() => UserKey.New(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs index 21f731ee..db6570c5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs @@ -2,21 +2,20 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace CodeBeam.UltimateAuth.Core.Infrastructure +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class UserKeyJsonConverter : JsonConverter { - public sealed class UserKeyJsonConverter : JsonConverter + public override UserKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - public override UserKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.String) - throw new JsonException("UserKey must be a string."); + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("UserKey must be a string."); - return UserKey.FromString(reader.GetString()!); - } + return UserKey.FromString(reader.GetString()!); + } - public override void Write(Utf8JsonWriter writer, UserKey value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.Value); - } + public override void Write(Utf8JsonWriter writer, UserKey value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); } } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs index af82c698..ffc9040e 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs @@ -1,37 +1,36 @@ -namespace CodeBeam.UltimateAuth.Core.MultiTenancy +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; + /// - /// Executes multiple tenant resolvers in order; the first resolver returning a non-null tenant id wins. + /// Creates a composite resolver that will evaluate the provided resolvers sequentially. /// - public sealed class CompositeTenantResolver : ITenantIdResolver + /// Ordered list of resolvers to execute. + public CompositeTenantResolver(IEnumerable resolvers) { - 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(); - } + _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) + /// + /// 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) { - foreach (var resolver in _resolvers) - { - var tid = await resolver.ResolveTenantIdAsync(context); - if (!string.IsNullOrWhiteSpace(tid)) - return tid; - } - - return null; + 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 index 28b85062..83a675a7 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs @@ -1,27 +1,16 @@ -namespace CodeBeam.UltimateAuth.Core.MultiTenancy +namespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +public sealed class FixedTenantResolver : ITenantIdResolver { - /// - /// Returns a constant tenant id for all resolution requests; useful for single-tenant or statically configured systems. - /// - public sealed class FixedTenantResolver : ITenantIdResolver - { - private readonly string _tenantId; + private readonly string _tenantId; - /// - /// Creates a resolver that always returns the specified tenant id. - /// - /// The tenant id that will be returned for all requests. - public FixedTenantResolver(string tenantId) - { - _tenantId = tenantId; - } + public FixedTenantResolver(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + throw new ArgumentException("Tenant id cannot be empty.", nameof(tenantId)); - /// - /// Returns the fixed tenant id regardless of context. - /// - public Task ResolveTenantIdAsync(TenantResolutionContext context) - { - return Task.FromResult(_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 index e969f0d7..512ef583 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs @@ -1,38 +1,37 @@ -namespace CodeBeam.UltimateAuth.Core.MultiTenancy +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; + /// - /// 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. + /// Creates a resolver that reads the tenant id from the given header name. /// - public sealed class HeaderTenantResolver : ITenantIdResolver + /// The name of the HTTP header to inspect. + public HeaderTenantResolver(string headerName) { - 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; - } + _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) + /// + /// 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)) { - if (context.Headers != null && - context.Headers.TryGetValue(_headerName, out var value) && - !string.IsNullOrWhiteSpace(value)) - { - return Task.FromResult(value); - } - - return Task.FromResult(null); + 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 index f411b8dd..02ab394f 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs @@ -1,30 +1,29 @@ -namespace CodeBeam.UltimateAuth.Core.MultiTenancy +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 { /// - /// Resolves the tenant id based on the request host name. - /// Example: foo.example.com → returns "foo". - /// Useful in subdomain-based multi-tenant architectures. + /// 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 sealed class HostTenantResolver : ITenantIdResolver + public Task ResolveTenantIdAsync(TenantResolutionContext context) { - /// - /// 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; + var host = context.Host; - if (string.IsNullOrWhiteSpace(host)) - return Task.FromResult(null); + if (string.IsNullOrWhiteSpace(host)) + return Task.FromResult(null); - var parts = host.Split('.', StringSplitOptions.RemoveEmptyEntries); + var parts = host.Split('.', StringSplitOptions.RemoveEmptyEntries); - // Expecting at least: {tenant}.{domain}.{tld} - if (parts.Length < 3) - return Task.FromResult(null); + // Expecting at least: {tenant}.{domain}.{tld} + if (parts.Length < 3) + return Task.FromResult(null); - return Task.FromResult(parts[0]); - } + return Task.FromResult(parts[0]); } } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs index cc7867c4..5289e08a 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs @@ -1,16 +1,15 @@ -namespace CodeBeam.UltimateAuth.Core.MultiTenancy +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 { /// - /// 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. + /// Attempts to resolve the tenant id given the contextual request data. + /// Returns null when no tenant can be determined. /// - 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); - } + Task ResolveTenantIdAsync(TenantResolutionContext context); } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs index 38c3f772..c04cafce 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs @@ -1,40 +1,39 @@ -namespace CodeBeam.UltimateAuth.Core.MultiTenancy +namespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +/// +/// Resolves the tenant id from the request path. +/// Example pattern: /t/{tenantId}/... → returns the extracted tenant id. +/// +public sealed class PathTenantResolver : ITenantIdResolver { + private readonly string _prefix; + /// - /// Resolves the tenant id from the request path. - /// Example pattern: /t/{tenantId}/... → returns the extracted tenant id. + /// Creates a resolver that looks for tenant ids under a specific URL prefix. + /// Default prefix is "t", meaning URLs like /t/foo/api will resolve "foo". /// - public sealed class PathTenantResolver : ITenantIdResolver + public PathTenantResolver(string prefix = "t") { - private readonly string _prefix; - - /// - /// Creates a resolver that looks for tenant ids under a specific URL prefix. - /// Default prefix is "t", meaning URLs like /t/foo/api will resolve "foo". - /// - public PathTenantResolver(string prefix = "t") - { - _prefix = prefix; - } - - /// - /// 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); + _prefix = prefix; + } - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + /// + /// 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); - // Format: /{prefix}/{tenantId}/... - if (segments.Length >= 2 && segments[0] == _prefix) - return Task.FromResult(segments[1]); + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - return Task.FromResult(null); - } + // Format: /{prefix}/{tenantId}/... + if (segments.Length >= 2 && segments[0] == _prefix) + return Task.FromResult(segments[1]); + 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..4a8ca986 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs @@ -0,0 +1,102 @@ +using System.Security; +using System.Text.RegularExpressions; + +namespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +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 index 6ae1c483..d5271f0d 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs @@ -1,66 +1,65 @@ -namespace CodeBeam.UltimateAuth.Core.MultiTenancy +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 { /// - /// Represents the normalized request information used during tenant resolution. - /// Resolvers inspect these fields to derive the correct tenant id. + /// The request host value (e.g., "foo.example.com"). + /// Used by HostTenantResolver. /// - public sealed class TenantResolutionContext - { - /// - /// The request host value (e.g., "foo.example.com"). - /// Used by HostTenantResolver. - /// - public string? Host { get; init; } + public string? Host { get; init; } - /// - /// The request path (e.g., "/t/foo/api/..."). - /// Used by PathTenantResolver. - /// - public string? Path { 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; } + /// + /// 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; } + /// + /// 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; } + /// + /// 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(); + /// + /// 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() { } + private TenantResolutionContext() { } - public static TenantResolutionContext Create( - IReadOnlyDictionary? headers = null, - IReadOnlyDictionary? Query = null, - string? host = null, - string? path = null, - object? rawContext = null) + public static TenantResolutionContext Create( + IReadOnlyDictionary? headers = null, + IReadOnlyDictionary? Query = null, + string? host = null, + string? path = null, + object? rawContext = null) + { + return new TenantResolutionContext { - return new TenantResolutionContext - { - Headers = headers, - Query = Query, - Host = host, - Path = path, - RawContext = rawContext - }; - } + 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/TenantValidation.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantValidation.cs deleted file mode 100644 index c33d8b77..00000000 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantValidation.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Text.RegularExpressions; -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Core.MultiTenancy -{ - internal static class TenantValidation - { - public static UAuthTenantContext FromResolvedTenant( - string rawTenantId, - UAuthMultiTenantOptions options) - { - if (string.IsNullOrWhiteSpace(rawTenantId)) - return UAuthTenantContext.NotResolved(); - - var tenantId = options.NormalizeToLowercase - ? rawTenantId.ToLowerInvariant() - : rawTenantId; - - if (!Regex.IsMatch(tenantId, options.TenantIdRegex)) - return UAuthTenantContext.NotResolved(); - - if (options.ReservedTenantIds.Contains(tenantId)) - return UAuthTenantContext.NotResolved(); - - return UAuthTenantContext.Resolved(tenantId); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs index 9874068a..017c36f9 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs @@ -1,23 +1,24 @@ -namespace CodeBeam.UltimateAuth.Core.MultiTenancy +namespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +/// +/// Represents the resolved tenant result for the current request. +/// +public sealed class UAuthTenantContext { - /// - /// Represents the resolved tenant result for the current request. - /// - public sealed class UAuthTenantContext + public TenantKey Tenant { get; } + + private UAuthTenantContext(TenantKey tenant) { - public string? TenantId { get; } - public bool IsResolved { get; } + if (tenant.IsUnresolved) + throw new InvalidOperationException("Runtime tenant context cannot be unresolved."); - private UAuthTenantContext(string? tenantId, bool resolved) - { - TenantId = tenantId; - IsResolved = resolved; - } + Tenant = tenant; + } - public static UAuthTenantContext NotResolved() - => new(null, false); + public bool IsSingleTenant => Tenant.IsSingle; + public bool IsSystem => Tenant.IsSystem; - public static UAuthTenantContext Resolved(string tenantId) - => new(tenantId, true); - } + public static UAuthTenantContext SingleTenant() => new(TenantKey.Single); + public static UAuthTenantContext System() => new(TenantKey.System); + public static UAuthTenantContext Resolved(TenantKey tenant) => new(tenant); } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs b/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs index 691dbd40..826703c8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +public enum HeaderTokenFormat { - public enum HeaderTokenFormat - { - Bearer, - Raw - } + Bearer, + Raw } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs b/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs index 42226d11..65236210 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs @@ -1,9 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; +namespace CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Core.Options +public interface IClientProfileDetector { - public interface IClientProfileDetector - { - UAuthClientProfile Detect(IServiceProvider services); - } + UAuthClientProfile Detect(IServiceProvider services); } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/IServerProfileDetector.cs b/src/CodeBeam.UltimateAuth.Core/Options/IServerProfileDetector.cs deleted file mode 100644 index 33af8f2b..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Options/IServerProfileDetector.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Core.Options -{ - public interface IServerProfileDetector - { - UAuthClientProfile Detect(IServiceProvider services); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs b/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs index ce777e15..5d5ded63 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +public enum TokenResponseMode { - public enum TokenResponseMode - { - None, - Cookie, - Header, - Body - } + None, + Cookie, + Header, + Body } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs index f8c75a2a..c5bf2c3c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs @@ -1,13 +1,12 @@ -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +public enum UAuthClientProfile { - public enum UAuthClientProfile - { - NotSpecified, - BlazorWasm, - BlazorServer, - Maui, - WebServer, - Api, - UAuthHub = 1000 - } + NotSpecified, + BlazorWasm, + BlazorServer, + Maui, + WebServer, + Api, + UAuthHub = 1000 } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 46677d74..0912e65a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -1,21 +1,20 @@ -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +/// +/// Configuration settings related to interactive user login behavior, +/// including lockout policies and failed-attempt thresholds. +/// +public sealed class UAuthLoginOptions { /// - /// Configuration settings related to interactive user login behavior, - /// including lockout policies and failed-attempt thresholds. + /// Maximum number of consecutive failed login attempts allowed + /// before the user is temporarily locked out. /// - public sealed class UAuthLoginOptions - { - /// - /// Maximum number of consecutive failed login attempts allowed - /// before the user is temporarily locked out. - /// - public int MaxFailedAttempts { get; set; } = 5; + public int MaxFailedAttempts { get; set; } = 5; - /// - /// Duration (in minutes) for which the user is locked out - /// after exceeding . - /// - public int LockoutMinutes { get; set; } = 15; - } + /// + /// Duration (in minutes) for which the user is locked out + /// after exceeding . + /// + public int LockoutMinutes { get; set; } = 15; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs index 941a93a5..e9d3533b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs @@ -1,43 +1,42 @@ -namespace CodeBeam.UltimateAuth.Core +namespace CodeBeam.UltimateAuth.Core; + +/// +/// Defines the authentication execution model for UltimateAuth. +/// Each mode represents a fundamentally different security +/// and lifecycle strategy. +/// +public enum UAuthMode { /// - /// Defines the authentication execution model for UltimateAuth. - /// Each mode represents a fundamentally different security - /// and lifecycle strategy. + /// Pure opaque, session-based authentication. + /// No JWT, no refresh token. + /// Full server-side control with sliding expiration. + /// Best for Blazor Server, MVC, intranet apps. /// - 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, + PureOpaque = 0, - /// - /// Full hybrid mode. - /// Session + JWT + refresh token. - /// Server-side session control with JWT performance. - /// Default mode. - /// - Hybrid = 1, + /// + /// 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, + /// + /// 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 - } + /// + /// 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 index 9c0fdec5..2d76b691 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs @@ -1,86 +1,59 @@ -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +/// +/// Multi-tenancy configuration for UltimateAuth. +/// Controls whether tenants are required, how they are resolved, +/// and how tenant identifiers are normalized. +/// +public sealed class UAuthMultiTenantOptions { /// - /// Multi-tenancy configuration for UltimateAuth. - /// Controls whether tenants are required, how they are resolved, - /// and how tenant identifiers are normalized. + /// Enables multi-tenant mode. + /// When disabled, all requests operate under a single implicit tenant. /// - 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 tenant cannot be resolved, this value is used. - /// If null and RequireTenant = true, request fails. - /// - public string? DefaultTenantId { get; set; } - - /// - /// If true, a resolved tenant id must always exist. - /// If resolver cannot determine tenant, request will fail. - /// - public bool RequireTenant { get; set; } = false; + public bool Enabled { get; set; } = false; - /// - /// If true, a tenant id returned by resolver does NOT need to be known beforehand. - /// If false, unknown tenants must be explicitly registered. - /// (Useful for multi-tenant SaaS with dynamic tenant provisioning) - /// - public bool AllowUnknownTenants { get; set; } = true; + /// + /// 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; - /// - /// Tenant ids that cannot be used by clients. - /// Protects system-level tenant identifiers. - /// - public HashSet ReservedTenantIds { get; set; } = new() - { - "system", - "root", - "admin", - "public" - }; + /// + /// If true, a tenant id returned by resolver does NOT need to be known beforehand. + /// If false, unknown tenants must be explicitly registered. + /// (Useful for multi-tenant SaaS with dynamic tenant provisioning) + /// + public bool AllowUnknownTenants { get; set; } = true; - /// - /// If true, tenant identifiers are normalized to lowercase. - /// Recommended for host-based tenancy. - /// - public bool NormalizeToLowercase { get; set; } = true; + /// + /// If true, tenant identifiers are normalized to lowercase. + /// Recommended for host-based tenancy. + /// + public bool NormalizeToLowercase { get; set; } = true; - /// - /// Optional validation for tenant id format. - /// Default: alphanumeric + hyphens allowed. - /// - public string TenantIdRegex { get; set; } = "^[a-zA-Z0-9\\-]+$"; - /// - /// Enables tenant resolution from the URL path and - /// exposes auth endpoints under /{tenant}/{routePrefix}/... - /// - public bool EnableRoute { get; set; } = true; - public bool EnableHeader { get; set; } = false; - public bool EnableDomain { get; set; } = false; + /// + /// Enables tenant resolution from the URL path and + /// exposes auth endpoints under /{tenant}/{routePrefix}/... + /// + public bool EnableRoute { get; set; } = true; + public bool EnableHeader { get; set; } = false; + public bool EnableDomain { get; set; } = false; - // Header config - public string HeaderName { get; set; } = "X-Tenant"; + // Header config + public string HeaderName { get; set; } = "X-Tenant"; - internal UAuthMultiTenantOptions Clone() => new() - { - Enabled = Enabled, - DefaultTenantId = DefaultTenantId, - RequireTenant = RequireTenant, - AllowUnknownTenants = AllowUnknownTenants, - ReservedTenantIds = new HashSet(ReservedTenantIds), - NormalizeToLowercase = NormalizeToLowercase, - TenantIdRegex = TenantIdRegex, - EnableRoute = EnableRoute, - EnableHeader = EnableHeader, - EnableDomain = EnableDomain, - HeaderName = HeaderName - }; + internal UAuthMultiTenantOptions Clone() => new() + { + Enabled = Enabled, + RequireTenant = RequireTenant, + AllowUnknownTenants = AllowUnknownTenants, + NormalizeToLowercase = NormalizeToLowercase, + EnableRoute = EnableRoute, + EnableHeader = EnableHeader, + EnableDomain = EnableDomain, + HeaderName = HeaderName + }; - } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs index 74828a1d..ec416dfc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs @@ -1,85 +1,31 @@ -using System.Text.RegularExpressions; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthMultiTenantOptionsValidator : IValidateOptions { - /// - /// Validates at application startup. - /// Ensures that tenant configuration values (regex patterns, defaults, - /// reserved identifiers, and requirement rules) are logically consistent - /// and safe to use before multi-tenant authentication begins. - /// - internal sealed class UAuthMultiTenantOptionsValidator : IValidateOptions + public ValidateOptionsResult Validate(string? name, UAuthMultiTenantOptions options) { - /// - /// Performs validation on the provided instance. - /// This method enforces: - /// - valid tenant id regex format, - /// - reserved tenant ids matching the regex, - /// - default tenant id consistency, - /// - requirement rules coherence. - /// - /// Optional configuration section name. - /// The options instance to validate. - /// - /// A indicating success or the - /// specific configuration error encountered. - /// - public ValidateOptionsResult Validate(string? name, UAuthMultiTenantOptions options) + if (!options.Enabled) { - // Multi-tenancy disabled → no validation needed - if (!options.Enabled) - return ValidateOptionsResult.Success; - - try - { - _ = new Regex(options.TenantIdRegex, RegexOptions.Compiled); - } - catch (Exception ex) - { - return ValidateOptionsResult.Fail( - $"Invalid TenantIdRegex '{options.TenantIdRegex}'. Regex error: {ex.Message}"); - } - - foreach (var reserved in options.ReservedTenantIds) - { - if (string.IsNullOrWhiteSpace(reserved)) - { - return ValidateOptionsResult.Fail( - "ReservedTenantIds cannot contain empty or whitespace values."); - } - - if (!Regex.IsMatch(reserved, options.TenantIdRegex)) - { - return ValidateOptionsResult.Fail( - $"Reserved tenant id '{reserved}' does not match TenantIdRegex '{options.TenantIdRegex}'."); - } - } - - if (options.DefaultTenantId != null) - { - if (string.IsNullOrWhiteSpace(options.DefaultTenantId)) - { - return ValidateOptionsResult.Fail("DefaultTenantId cannot be empty or whitespace."); - } - - if (!Regex.IsMatch(options.DefaultTenantId, options.TenantIdRegex)) - { - return ValidateOptionsResult.Fail($"DefaultTenantId '{options.DefaultTenantId}' does not match TenantIdRegex '{options.TenantIdRegex}'."); - } - - if (options.ReservedTenantIds.Contains(options.DefaultTenantId)) - { - return ValidateOptionsResult.Fail($"DefaultTenantId '{options.DefaultTenantId}' is listed in ReservedTenantIds."); - } - } - - if (options.RequireTenant && options.DefaultTenantId == null) + if (options.RequireTenant) { - return ValidateOptionsResult.Fail("RequireTenant = true, but DefaultTenantId is null. Provide a default tenant id or disable RequireTenant."); + return ValidateOptionsResult.Fail("RequireTenant cannot be true when multi-tenancy is disabled."); } return ValidateOptionsResult.Success; } + + if (!options.EnableRoute && + !options.EnableHeader && + !options.EnableDomain) + { + return ValidateOptionsResult.Fail( + "Multi-tenancy is enabled but no tenant resolver is active " + + "(route, header, or domain)."); + } + + return ValidateOptionsResult.Success; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs index 8992fb19..a65a1d51 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs @@ -1,61 +1,60 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Events; -namespace CodeBeam.UltimateAuth.Core.Options +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 { /// - /// 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(). + /// Configuration settings for interactive login flows, + /// including lockout thresholds and failed-attempt policies. /// - public sealed class UAuthOptions - { - /// - /// 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 UAuthEvents { get; set; } = new(); - - /// - /// Multi-tenancy configuration controlling how tenants are resolved, - /// validated, and optionally enforced. - /// - public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); - - /// - /// Provides converters used to normalize and serialize TUserId - /// across the system (sessions, stores, tokens, logging). - /// - public IUserIdConverterResolver? UserIdConverters { get; set; } - - public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; - public bool AutoDetectClientProfile { get; set; } = true; - } + 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 UAuthEvents { get; set; } = new(); + + /// + /// Multi-tenancy configuration controlling how tenants are resolved, + /// validated, and optionally enforced. + /// + public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + + /// + /// Provides converters used to normalize and serialize TUserId + /// across the system (sessions, stores, tokens, logging). + /// + public IUserIdConverterResolver? UserIdConverters { get; set; } + + public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; + public bool AutoDetectClientProfile { get; set; } = true; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs index 405681e0..aa7dff30 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs @@ -1,44 +1,43 @@ using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthOptionsValidator : IValidateOptions { - internal sealed class UAuthOptionsValidator : IValidateOptions + public ValidateOptionsResult Validate(string? name, UAuthOptions options) { - public ValidateOptionsResult Validate(string? name, UAuthOptions options) - { - var errors = new List(); + var errors = new List(); - if (options.Login is null) - errors.Add("UltimateAuth.Login configuration section is missing."); + 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.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.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.Pkce is null) + errors.Add("UltimateAuth.Pkce configuration section is missing."); - if (errors.Count > 0) - return ValidateOptionsResult.Fail(errors); + 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."); - } + // 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."); + } - return errors.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(errors); + 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/UAuthPkceOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs index b66d85cf..ab13f894 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs @@ -1,28 +1,25 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +namespace CodeBeam.UltimateAuth.Core.Options; -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 { /// - /// 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. + /// Lifetime of a PKCE authorization code in seconds. + /// Shorter values provide stronger replay protection, + /// while longer values allow more tolerance for slow clients. /// - 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; + public int AuthorizationCodeLifetimeSeconds { get; set; } = 120; - public int MaxVerificationAttempts { get; set; } = 5; + public int MaxVerificationAttempts { get; set; } = 5; - internal UAuthPkceOptions Clone() => new() - { - AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds, - MaxVerificationAttempts = MaxVerificationAttempts, - }; + internal UAuthPkceOptions Clone() => new() + { + AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds, + MaxVerificationAttempts = MaxVerificationAttempts, + }; - } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs index 744d81a5..4ee9c2ab 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs @@ -1,21 +1,20 @@ using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthPkceOptionsValidator : IValidateOptions { - internal sealed class UAuthPkceOptionsValidator : IValidateOptions + public ValidateOptionsResult Validate(string? name, UAuthPkceOptions options) { - public ValidateOptionsResult Validate(string? name, UAuthPkceOptions options) - { - var errors = new List(); - - if (options.AuthorizationCodeLifetimeSeconds <= 0) - { - errors.Add("Pkce.AuthorizationCodeLifetimeSeconds must be > 0."); - } + var errors = new List(); - return errors.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(errors); + 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/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 2d323487..35fd2631 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -1,112 +1,111 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Options +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. + +/// +/// 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 { - // 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. + /// + /// 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); /// - /// 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. + /// Maximum number of device session chains a single user may have. + /// Set to zero to indicate no user-level chain limit. /// - public sealed class UAuthSessionOptions + 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). + /// + public Dictionary? MaxChainsPerPlatform { get; set; } + + /// + /// Defines platform categories that map multiple platforms + /// into a single abstract group (e.g. mobile: [ "ios", "android", "tablet" ]). + /// + public Dictionary? PlatformCategories { get; set; } + + /// + /// Limits how many session chains can exist per platform category + /// (e.g. mobile = 1, desktop = 2). + /// + public Dictionary? MaxChainsPerCategory { get; set; } + + /// + /// Enables binding sessions to the user's IP address. + /// When enabled, IP mismatches can invalidate a session. + /// + public bool EnableIpBinding { get; set; } = false; + + /// + /// Enables binding sessions to the user's User-Agent header. + /// When enabled, UA mismatches can invalidate a session. + /// + public bool EnableUserAgentBinding { get; set; } = false; + + public DeviceMismatchBehavior DeviceMismatchBehavior { get; set; } = DeviceMismatchBehavior.Reject; + + internal UAuthSessionOptions Clone() => new() { - /// - /// 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. - /// - 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). - /// - public Dictionary? MaxChainsPerPlatform { get; set; } - - /// - /// Defines platform categories that map multiple platforms - /// into a single abstract group (e.g. mobile: [ "ios", "android", "tablet" ]). - /// - public Dictionary? PlatformCategories { get; set; } - - /// - /// Limits how many session chains can exist per platform category - /// (e.g. mobile = 1, desktop = 2). - /// - public Dictionary? MaxChainsPerCategory { get; set; } - - /// - /// Enables binding sessions to the user's IP address. - /// When enabled, IP mismatches can invalidate a session. - /// - public bool EnableIpBinding { get; set; } = false; - - /// - /// Enables binding sessions to the user's User-Agent header. - /// When enabled, UA mismatches can invalidate a session. - /// - public bool EnableUserAgentBinding { get; set; } = false; - - 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 - }; - - } + 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/UAuthSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs index 1d81b1d0..c757772b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs @@ -1,100 +1,99 @@ using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthSessionOptionsValidator : IValidateOptions { - internal sealed class UAuthSessionOptionsValidator : IValidateOptions + public ValidateOptionsResult Validate(string? name, UAuthSessionOptions options) { - public ValidateOptionsResult Validate(string? name, UAuthSessionOptions options) - { - var errors = new List(); + var errors = new List(); - if (options.Lifetime <= TimeSpan.Zero) - errors.Add("Session.Lifetime must be greater than zero."); + 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.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) + 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.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.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.MaxSessionsPerChain <= 0) + errors.Add("Session.MaxSessionsPerChain must be at least 1."); - if (options.MaxChainsPerPlatform != null) + if (options.MaxChainsPerPlatform != null) + { + foreach (var kv in options.MaxChainsPerPlatform) { - foreach (var kv in options.MaxChainsPerPlatform) - { - if (string.IsNullOrWhiteSpace(kv.Key)) - errors.Add("Session.MaxChainsPerPlatform contains an empty platform key."); + 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 (kv.Value <= 0) + errors.Add($"Session.MaxChainsPerPlatform['{kv.Key}'] must be >= 1."); } + } - if (options.PlatformCategories != null) + if (options.PlatformCategories != null) + { + foreach (var cat in options.PlatformCategories) { - 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) { - 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)}"); - } + errors.Add($"Session.PlatformCategories['{categoryName}'] contains duplicate platforms: {string.Join(", ", duplicates)}"); } } + } - if (options.MaxChainsPerCategory != null) + if (options.MaxChainsPerCategory != null) + { + foreach (var kv in options.MaxChainsPerCategory) { - foreach (var kv in options.MaxChainsPerCategory) - { - if (string.IsNullOrWhiteSpace(kv.Key)) - errors.Add("Session.MaxChainsPerCategory contains an empty category key."); + 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 (kv.Value <= 0) + errors.Add($"Session.MaxChainsPerCategory['{kv.Key}'] must be >= 1."); } + } - if (options.PlatformCategories != null && options.MaxChainsPerCategory != null) + if (options.PlatformCategories != null && options.MaxChainsPerCategory != null) + { + foreach (var category in options.PlatformCategories.Keys) { - foreach (var category in options.PlatformCategories.Keys) + if (!options.MaxChainsPerCategory.ContainsKey(category)) { - if (!options.MaxChainsPerCategory.ContainsKey(category)) - { - errors.Add( - $"Session.MaxChainsPerCategory must define a limit for category '{category}' " + - "because it exists in Session.PlatformCategories."); - } + errors.Add( + $"Session.MaxChainsPerCategory must define a limit for category '{category}' " + + "because it exists in Session.PlatformCategories."); } } + } - if (errors.Count == 0) - return ValidateOptionsResult.Success; + if (errors.Count == 0) + return ValidateOptionsResult.Success; - return ValidateOptionsResult.Fail(errors); - } + return ValidateOptionsResult.Fail(errors); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs index 5ef2e046..9afd48d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -1,80 +1,79 @@ -namespace CodeBeam.UltimateAuth.Core.Options +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 { /// - /// Configuration settings for access and refresh token behavior - /// within UltimateAuth. Includes JWT and opaque token generation, - /// lifetimes, and cryptographic settings. + /// Determines whether JWT-format access tokens should be issued. + /// Recommended for APIs that rely on claims-based authorization. /// - 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; + 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; + /// + /// 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; + 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 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); + /// + /// 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; + /// + /// 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 "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"; + /// + /// 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; + /// + /// 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; } + /// + /// 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 - }; + 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/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs index c9de6e06..7d374cb0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs @@ -1,49 +1,48 @@ using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Core.Options +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthTokenOptionsValidator : IValidateOptions { - internal sealed class UAuthTokenOptionsValidator : IValidateOptions + public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) { - public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) - { - var errors = new List(); + var errors = new List(); - if (!options.IssueJwt && !options.IssueOpaque) - errors.Add("Token: At least one of IssueJwt or IssueOpaque must be enabled."); + 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.AccessTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.AccessTokenLifetime must be greater than zero."); - if (options.RefreshTokenLifetime <= TimeSpan.Zero) - errors.Add("Token.RefreshTokenLifetime must be greater than zero."); + if (options.RefreshTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.RefreshTokenLifetime must be greater than zero."); - if (options.RefreshTokenLifetime <= options.AccessTokenLifetime) - errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); + if (options.RefreshTokenLifetime <= options.AccessTokenLifetime) + errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); - if (options.IssueJwt) - { - if (string.IsNullOrWhiteSpace(options.Issuer)) // TODO: Min 3 chars - errors.Add("Token.Issuer must not be empty when IssueJwt = true."); + if (options.IssueJwt) + { + if (string.IsNullOrWhiteSpace(options.Issuer)) // TODO: Min 3 chars + errors.Add("Token.Issuer must not be empty when IssueJwt = true."); - if (string.IsNullOrWhiteSpace(options.Audience)) - errors.Add("Token.Audience must not be empty when IssueJwt = true."); - } + if (string.IsNullOrWhiteSpace(options.Audience)) + errors.Add("Token.Audience must not be empty when IssueJwt = true."); + } - if (options.IssueOpaque) - { - if (options.OpaqueIdBytes < 16) - errors.Add("Token.OpaqueIdBytes must be at least 16 (128-bit entropy)."); - } + if (options.IssueOpaque) + { + if (options.OpaqueIdBytes < 16) + errors.Add("Token.OpaqueIdBytes must be at least 16 (128-bit entropy)."); + } - if (options.IssueRefresh && options.RefreshTokenLifetime <= TimeSpan.Zero) - { - errors.Add("RefreshTokenLifetime must be set when IssueRefresh is enabled."); - } + if (options.IssueRefresh && options.RefreshTokenLifetime <= TimeSpan.Zero) + { + errors.Add("RefreshTokenLifetime must be set when IssueRefresh is enabled."); + } - return errors.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(errors); - } + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs index 495e3cc4..d91d578b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Runtime +namespace CodeBeam.UltimateAuth.Core.Runtime; + +/// +/// Marker interface indicating that the current application +/// hosts an UltimateAuth Hub. +/// +public interface IUAuthHubMarker { - /// - /// Marker interface indicating that the current application - /// hosts an UltimateAuth Hub. - /// - public interface IUAuthHubMarker - { - } } diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs index e7345c0b..d5238bce 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs @@ -1,9 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Runtime; +namespace CodeBeam.UltimateAuth.Core.Runtime; -namespace CodeBeam.UltimateAuth.Core.Runtime +public interface IUAuthProductInfoProvider { - public interface IUAuthProductInfoProvider - { - UAuthProductInfo Get(); - } + UAuthProductInfo Get(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs index 3b28ca6a..629c25a9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs @@ -1,17 +1,16 @@ using CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Core.Runtime +namespace CodeBeam.UltimateAuth.Core.Runtime; + +public sealed class UAuthProductInfo { - public sealed class UAuthProductInfo - { - public string ProductName { get; init; } = "UltimateAuth"; - public string Version { get; init; } = default!; - public string? InformationalVersion { get; init; } + public string ProductName { get; init; } = "UltimateAuth"; + public string Version { get; init; } = default!; + public string? InformationalVersion { get; init; } - public UAuthClientProfile ClientProfile { get; init; } - public bool ClientProfileAutoDetected { get; init; } + public UAuthClientProfile ClientProfile { get; init; } + public bool ClientProfileAutoDetected { get; init; } - public DateTimeOffset StartedAt { get; init; } - public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); - } + 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 index d6da1567..90de44f8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs @@ -2,27 +2,26 @@ using Microsoft.Extensions.Options; using System.Reflection; -namespace CodeBeam.UltimateAuth.Core.Runtime +namespace CodeBeam.UltimateAuth.Core.Runtime; + +internal sealed class UAuthProductInfoProvider : IUAuthProductInfoProvider { - internal sealed class UAuthProductInfoProvider : IUAuthProductInfoProvider + private readonly UAuthProductInfo _info; + + public UAuthProductInfoProvider(IOptions options) { - private readonly UAuthProductInfo _info; + var asm = typeof(UAuthProductInfoProvider).Assembly; - public UAuthProductInfoProvider(IOptions options) + _info = new UAuthProductInfo { - var asm = typeof(UAuthProductInfoProvider).Assembly; - - _info = new UAuthProductInfo - { - Version = asm.GetName().Version?.ToString(3) ?? "unknown", - InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, + Version = asm.GetName().Version?.ToString(3) ?? "unknown", + InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, - ClientProfile = options.Value.ClientProfile, - ClientProfileAutoDetected = options.Value.AutoDetectClientProfile, - StartedAt = DateTimeOffset.UtcNow - }; - } - - public UAuthProductInfo Get() => _info; + ClientProfile = options.Value.ClientProfile, + ClientProfileAutoDetected = options.Value.AutoDetectClientProfile, + StartedAt = DateTimeOffset.UtcNow + }; } + + public UAuthProductInfo Get() => _info; } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index 950b8fa3..11e5e962 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -2,12 +2,11 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Abstractions +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +public interface ICredentialResponseWriter { - public interface ICredentialResponseWriter - { - void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId); - void Write(HttpContext context, CredentialKind kind, AccessToken accessToken); - void Write(HttpContext context, CredentialKind kind, RefreshToken refreshToken); - } + void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId); + void Write(HttpContext context, CredentialKind kind, AccessToken accessToken); + void Write(HttpContext context, CredentialKind kind, RefreshToken refreshToken); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs index 06b0998a..8be92d7a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs @@ -1,13 +1,12 @@ using Microsoft.AspNetCore.Http; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Abstractions +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +/// +/// Resolves device and client metadata from the current HTTP context. +/// +public interface IDeviceResolver { - /// - /// Resolves device and client metadata from the current HTTP context. - /// - public interface IDeviceResolver - { - DeviceInfo Resolve(HttpContext context); - } + DeviceInfo Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs deleted file mode 100644 index 75edff58..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IHttpSessionIssuer.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Abstractions -{ - /// - /// HTTP-aware session issuer used by UltimateAuth server components. - /// Extends the core ISessionIssuer contract with HttpContext-bound - /// operations required for cookie-based session binding. - /// - public interface IHttpSessionIssuer : ISessionIssuer - { - Task IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default); - - Task RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs index 554e9263..52d54c7e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Abstractions +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +public interface IPrimaryCredentialResolver { - public interface IPrimaryCredentialResolver - { - PrimaryCredentialKind Resolve(HttpContext context); - } + PrimaryCredentialKind Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs index 1a727852..237668d9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs @@ -1,13 +1,12 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Abstractions +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +public interface IRefreshTokenResolver { - public interface IRefreshTokenResolver - { - /// - /// Resolves refresh token from incoming HTTP request. - /// Returns null if no refresh token is present. - /// - string? Resolve(HttpContext context); - } + /// + /// 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 index 74ae5b49..189b43ac 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs @@ -1,17 +1,8 @@ -using Microsoft.IdentityModel.Tokens; +using CodeBeam.UltimateAuth.Server.Contracts; -namespace CodeBeam.UltimateAuth.Server.Abstractions -{ - public interface IJwtSigningKeyProvider - { - JwtSigningKey Resolve(string? keyId); - } - - public sealed class JwtSigningKey - { - public required string KeyId { get; init; } - public required SecurityKey Key { get; init; } - public required string Algorithm { get; init; } - } +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 index 885b37e6..9bb61132 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -1,15 +1,14 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Server.Abstactions +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 { - /// - /// 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); - } + 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/Abstractions/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs deleted file mode 100644 index 6c030046..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ResolvedCredential.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Abstractions -{ - public sealed record ResolvedCredential - { - public PrimaryCredentialKind Kind { get; init; } - - /// - /// Raw credential value (session id / jwt / opaque) - /// - public string Value { get; init; } = default!; - - public string? TenantId { get; init; } = default!; - - public DeviceInfo Device { get; init; } = default!; - } -} 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/DefaultAuthFlowContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs deleted file mode 100644 index 6313300f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/DefaultAuthFlowContextAccessor.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Auth -{ - internal sealed class DefaultAuthFlowContextAccessor : IAuthFlowContextAccessor - { - private static readonly object Key = new(); - - private readonly IHttpContextAccessor _http; - - public DefaultAuthFlowContextAccessor(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; - } - - //private static readonly AsyncLocal _current = new(); - - //public AuthFlowContext Current => _current.Value ?? throw new InvalidOperationException("AuthFlowContext is not available for this request."); - - //internal void Set(AuthFlowContext context) - //{ - // _current.Value = context; - //} - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs index 0b4666fc..6be75e85 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs @@ -1,8 +1,6 @@ -namespace CodeBeam.UltimateAuth.Server.Auth -{ - public interface IAuthFlowContextAccessor - { - AuthFlowContext Current { get; } - } +namespace CodeBeam.UltimateAuth.Server.Auth; +public interface IAuthFlowContextAccessor +{ + AuthFlowContext Current { get; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs new file mode 100644 index 00000000..44888e2a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class ClientProfileReader : IClientProfileReader +{ + private const string HeaderName = "X-UAuth-ClientProfile"; + private const string FormFieldName = "__uauth_client_profile"; + + public UAuthClientProfile Read(HttpContext context) + { + if (context.Request.Headers.TryGetValue(HeaderName, out var headerValue) && TryParse(headerValue, out var headerProfile)) + { + return headerProfile; + } + + if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(FormFieldName, 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..484c38f5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -0,0 +1,66 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.ObjectModel; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AccessContextFactory : IAccessContextFactory +{ + private readonly IUserRoleStore _roleStore; + + public AccessContextFactory(IUserRoleStore roleStore) + { + _roleStore = roleStore; + } + + 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 roles = await _roleStore.GetRolesAsync(authFlow.Tenant, authFlow.UserKey.Value, ct); + attrs["roles"] = roles; + } + + return new AccessContext + { + ActorUserKey = authFlow.UserKey, + ActorTenant = authFlow.Tenant, + IsAuthenticated = authFlow.IsAuthenticated, + IsSystemActor = authFlow.Tenant.IsSystem, + + Resource = resource, + ResourceId = resourceId, + 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 index bb1fff7a..c163f0fd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed record AuthExecutionContext { - public sealed record AuthExecutionContext - { - public required UAuthClientProfile? EffectiveClientProfile { get; init; } - } + public required UAuthClientProfile? EffectiveClientProfile { 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 index 253b0c65..f68bee45 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -1,64 +1,70 @@ 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.Options; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class AuthFlowContext { - public sealed class AuthFlowContext - { - public AuthFlowType FlowType { get; } - public UAuthClientProfile ClientProfile { get; } - public UAuthMode EffectiveMode { get; } - public DeviceContext Device { get; } + 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 string? TenantId { get; } - public SessionSecurityContext? Session { get; } - public bool IsAuthenticated { get; } - public UserKey? UserKey { get; } + public UAuthServerOptions OriginalOptions { get; } + public EffectiveUAuthServerOptions EffectiveOptions { get; } - public UAuthServerOptions OriginalOptions { get; } - public EffectiveUAuthServerOptions EffectiveOptions { get; } + public EffectiveAuthResponse Response { get; } + public PrimaryTokenKind PrimaryTokenKind { get; } - public EffectiveAuthResponse Response { get; } - public PrimaryTokenKind PrimaryTokenKind { get; } + // Helpers + public bool AllowsTokenIssuance => + Response.AccessTokenDelivery.Mode != TokenResponseMode.None || + Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; - // 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) + { + if (tenantKey.IsUnresolved) + throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); - internal AuthFlowContext( - AuthFlowType flowType, - UAuthClientProfile clientProfile, - UAuthMode effectiveMode, - DeviceContext device, - string? tenantId, - bool isAuthenticated, - UserKey? userKey, - SessionSecurityContext? session, - UAuthServerOptions originalOptions, - EffectiveUAuthServerOptions effectiveOptions, - EffectiveAuthResponse response, - PrimaryTokenKind primaryTokenKind) - { - FlowType = flowType; - ClientProfile = clientProfile; - EffectiveMode = effectiveMode; - Device = device; + FlowType = flowType; + ClientProfile = clientProfile; + EffectiveMode = effectiveMode; + Device = device; - TenantId = tenantId; - Session = session; - IsAuthenticated = isAuthenticated; - UserKey = userKey; + Tenant = tenantKey; + Session = session; + IsAuthenticated = isAuthenticated; + UserKey = userKey; - OriginalOptions = originalOptions; - EffectiveOptions = effectiveOptions; + OriginalOptions = originalOptions; + EffectiveOptions = effectiveOptions; - Response = response; - PrimaryTokenKind = primaryTokenKind; - } + Response = response; + PrimaryTokenKind = primaryTokenKind; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index 6b3e618d..3521182d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -1,100 +1,100 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthFlowContextFactory : IAuthFlowContextFactory { - public interface 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) { - ValueTask CreateAsync(HttpContext httpContext, AuthFlowType flowType, CancellationToken ct = default); + _clientProfileReader = clientProfileReader; + _primaryTokenResolver = primaryTokenResolver; + _serverOptionsProvider = serverOptionsProvider; + _authResponseResolver = authResponseResolver; + _deviceResolver = deviceResolver; + _deviceContextFactory = deviceContextFactory; + _sessionValidator = sessionValidator; + _clock = clock; } - internal sealed class DefaultAuthFlowContextFactory : IAuthFlowContextFactory + public async ValueTask CreateAsync(HttpContext ctx, AuthFlowType flowType, CancellationToken ct = default) { - 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 ISessionQueryService _sessionQueryService; - - public DefaultAuthFlowContextFactory( - IClientProfileReader clientProfileReader, - IPrimaryTokenResolver primaryTokenResolver, - IEffectiveServerOptionsProvider serverOptionsProvider, - IAuthResponseResolver authResponseResolver, - IDeviceResolver deviceResolver, - IDeviceContextFactory deviceContextFactory, - ISessionQueryService sessionQueryService) - { - _clientProfileReader = clientProfileReader; - _primaryTokenResolver = primaryTokenResolver; - _serverOptionsProvider = serverOptionsProvider; - _authResponseResolver = authResponseResolver; - _deviceResolver = deviceResolver; - _deviceContextFactory = deviceContextFactory; - _sessionQueryService = sessionQueryService; - } + var tenant = ctx.GetTenant(); + var sessionCtx = ctx.GetSessionContext(); + var user = ctx.GetUserContext(); - public async ValueTask CreateAsync(HttpContext ctx, AuthFlowType flowType, CancellationToken ct = default) - { - var tenant = ctx.GetTenantContext(); - var sessionCtx = ctx.GetSessionContext(); - var user = ctx.GetUserContext(); + var clientProfile = _clientProfileReader.Read(ctx); + var originalOptions = _serverOptionsProvider.GetOriginal(ctx); + var effectiveOptions = _serverOptionsProvider.GetEffective(ctx, flowType, clientProfile); - var clientProfile = _clientProfileReader.Read(ctx); - var originalOptions = _serverOptionsProvider.GetOriginal(ctx); - var effectiveOptions = _serverOptionsProvider.GetEffective(ctx, flowType, clientProfile); + var effectiveMode = effectiveOptions.Mode; + var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); - var effectiveMode = effectiveOptions.Mode; - var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); + var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions); - var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions); + var deviceInfo = _deviceResolver.Resolve(ctx); + var deviceContext = _deviceContextFactory.Create(deviceInfo); - var deviceInfo = _deviceResolver.Resolve(ctx); - var deviceContext = _deviceContextFactory.Create(deviceInfo); + SessionSecurityContext? sessionSecurityContext = null; - 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); - if (!sessionCtx.IsAnonymous) - { - var validation = await _sessionQueryService.ValidateSessionAsync( - new SessionValidationContext - { - TenantId = sessionCtx.TenantId, - SessionId = sessionCtx.SessionId!.Value, - Device = deviceContext, - Now = DateTimeOffset.UtcNow - }, - ct); + sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation); + } - 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?.TenantId, - user?.IsAuthenticated ?? false, - user?.UserId, - sessionSecurityContext, - originalOptions, - effectiveOptions, - response, - primaryTokenKind - ); - } + // 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 + ); } + } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs index f1e4bcef..2c6304d3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs @@ -1,26 +1,24 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthFlowEndpointFilter : IEndpointFilter { - internal sealed class AuthFlowEndpointFilter : IEndpointFilter + private readonly IAuthFlow _authFlow; + + public AuthFlowEndpointFilter(IAuthFlow authFlow) { - private readonly IAuthFlow _authFlow; + _authFlow = authFlow; + } - public AuthFlowEndpointFilter(IAuthFlow authFlow) - { - _authFlow = authFlow; - } + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var metadata = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata(); - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + if (metadata != null) { - var metadata = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata(); - - if (metadata != null) - { - await _authFlow.BeginAsync(metadata.FlowType); - } - return await next(context); + 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 index 8c46b33f..f02bdbfe 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowMetadata.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowMetadata.cs @@ -1,14 +1,13 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class AuthFlowMetadata { - public sealed class AuthFlowMetadata - { - public AuthFlowType FlowType { get; } + public AuthFlowType FlowType { get; } - public AuthFlowMetadata(AuthFlowType flowType) - { - FlowType = flowType; - } + public AuthFlowMetadata(AuthFlowType flowType) + { + FlowType = flowType; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAccessContextFactory.cs deleted file mode 100644 index 9ff7b684..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAccessContextFactory.cs +++ /dev/null @@ -1,53 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Core.Contracts; -using System.Collections.ObjectModel; - -namespace CodeBeam.UltimateAuth.Server.Auth -{ - internal sealed class DefaultAccessContextFactory : IAccessContextFactory - { - private readonly IUserRoleStore _roleStore; - - public DefaultAccessContextFactory(IUserRoleStore roleStore) - { - _roleStore = roleStore; - } - - public async Task CreateAsync(AuthFlowContext authFlow, string action, string resource, string? resourceId = null, string? resourceTenantId = null, IDictionary? attributes = null, CancellationToken ct = default) - { - 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 is not null - ? new Dictionary(attributes) - : new Dictionary(); - - if (authFlow.IsAuthenticated && authFlow.UserKey is not null) - { - var roles = await _roleStore.GetRolesAsync(authFlow.TenantId, authFlow.UserKey.Value, ct); - attrs["roles"] = roles; - } - - return new AccessContext - { - ActorUserKey = authFlow.UserKey, - ActorTenantId = authFlow.TenantId, - IsAuthenticated = authFlow.IsAuthenticated, - IsSystemActor = false, - - Resource = resource, - ResourceId = resourceId, - ResourceTenantId = resourceTenantId ?? authFlow.TenantId, - - Action = action, - - Attributes = attrs.Count > 0 - ? new ReadOnlyDictionary(attrs) - : EmptyAttributes.Instance - }; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthContextFactory.cs deleted file mode 100644 index 8d11f2b9..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthContextFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Extensions; - -namespace CodeBeam.UltimateAuth.Server.Auth -{ - internal sealed class DefaultAuthContextFactory : IAuthContextFactory - { - private readonly IAuthFlowContextAccessor _flow; - private readonly IClock _clock; - - public DefaultAuthContextFactory(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/DefaultAuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs deleted file mode 100644 index 2340fd38..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/DefaultAuthFlow.cs +++ /dev/null @@ -1,30 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Auth -{ - internal sealed class DefaultAuthFlow : IAuthFlow - { - private readonly IHttpContextAccessor _http; - private readonly IAuthFlowContextFactory _factory; - private readonly DefaultAuthFlowContextAccessor _accessor; - - public DefaultAuthFlow(IHttpContextAccessor http, IAuthFlowContextFactory factory, IAuthFlowContextAccessor accessor) - { - _http = http; - _factory = factory; - _accessor = (DefaultAuthFlowContextAccessor)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/IAccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAccessContextFactory.cs index 4bc5aaad..7c6a5fe2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAccessContextFactory.cs @@ -1,9 +1,10 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAccessContextFactory { - public interface IAccessContextFactory - { - Task CreateAsync(AuthFlowContext authFlow, string action, string resource, string? resourceId = null, string? resourceTenantId = null, IDictionary? attributes = null, CancellationToken ct = default); - } + 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 index 41c6e1e9..1584acb0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAuthFlow { - public interface IAuthFlow - { - ValueTask BeginAsync(AuthFlowType flowType, CancellationToken ct = default); - } + 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..2aff1465 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAuthFlowContextFactory +{ + ValueTask CreateAsync(HttpContext httpContext, AuthFlowType flowType, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/DefaultClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultClientProfileReader.cs deleted file mode 100644 index 0ef907c7..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/DefaultClientProfileReader.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Auth -{ - internal sealed class DefaultClientProfileReader : IClientProfileReader - { - private const string HeaderName = "X-UAuth-ClientProfile"; - private const string FormFieldName = "__uauth_client_profile"; - - public UAuthClientProfile Read(HttpContext context) - { - if (context.Request.Headers.TryGetValue(HeaderName, out var headerValue) && TryParse(headerValue, out var headerProfile)) - { - return headerProfile; - } - - if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(FormFieldName, 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/DefaultEffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultEffectiveServerOptionsProvider.cs deleted file mode 100644 index c8188cf1..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/DefaultEffectiveServerOptionsProvider.cs +++ /dev/null @@ -1,47 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Auth; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Options -{ - internal sealed class DefaultEffectiveServerOptionsProvider : IEffectiveServerOptionsProvider - { - private readonly IOptions _baseOptions; - private readonly IEffectiveAuthModeResolver _modeResolver; - - public DefaultEffectiveServerOptionsProvider(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 original = _baseOptions.Value; - var effectiveMode = _modeResolver.Resolve(original.Mode, clientProfile, flowType); - var options = original.Clone(); - options.Mode = effectiveMode; - - ConfigureDefaults.ApplyModeDefaults(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/DefaultPrimaryTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/DefaultPrimaryTokenResolver.cs deleted file mode 100644 index 8d8ff763..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/DefaultPrimaryTokenResolver.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Auth -{ - internal sealed class DefaultPrimaryTokenResolver : 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/EffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs new file mode 100644 index 00000000..5f93b4c0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs @@ -0,0 +1,46 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +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 original = _baseOptions.Value; + var effectiveMode = _modeResolver.Resolve(original.Mode, clientProfile, flowType); + var options = original.Clone(); + options.Mode = effectiveMode; + + ConfigureDefaults.ApplyModeDefaults(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 index cf76608c..22cf6f84 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs @@ -1,17 +1,16 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class EffectiveUAuthServerOptions { - public sealed class EffectiveUAuthServerOptions - { - public UAuthMode Mode { get; init; } + public UAuthMode Mode { get; init; } - /// - /// Cloned, per-request server options - /// - public UAuthServerOptions Options { get; init; } = default!; + /// + /// Cloned, per-request server options + /// + public UAuthServerOptions Options { get; init; } = default!; - public AuthResponseOptions AuthResponse => Options.AuthResponse; - } + public AuthResponseOptions AuthResponse => Options.AuthResponse; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs index 5b49da9b..e64d3d7a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IClientProfileReader { - public interface IClientProfileReader - { - UAuthClientProfile Read(HttpContext context); - } + UAuthClientProfile Read(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs index 248bfaf0..b53d9ef8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IPrimaryTokenResolver { - public interface IPrimaryTokenResolver - { - PrimaryTokenKind Resolve(UAuthMode effectiveMode); - } + 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 index 0fc05b22..8eae3aba 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs @@ -4,132 +4,131 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthResponseOptionsModeTemplateResolver { - internal sealed class AuthResponseOptionsModeTemplateResolver + public AuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) { - public AuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) + return mode switch { - 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}") - }; - } + UAuthMode.PureOpaque => PureOpaque(flowType), + UAuthMode.Hybrid => Hybrid(flowType), + UAuthMode.SemiHybrid => SemiHybrid(flowType), + UAuthMode.PureJwt => PureJwt(flowType), + _ => throw new InvalidOperationException($"Unsupported mode: {mode}") + }; + } - private static AuthResponseOptions PureOpaque(AuthFlowType flow) - => new() + private static AuthResponseOptions PureOpaque(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() { - SessionIdDelivery = new() - { - Name = "uas", - Kind = CredentialKind.Session, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.Cookie, - }, - AccessTokenDelivery = new() - { - Name = "uat", - Kind = CredentialKind.AccessToken, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.None - }, - RefreshTokenDelivery = new() - { - Name = "uar", - Kind = CredentialKind.RefreshToken, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.None - }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } - }; + Name = "uas", + Kind = CredentialKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie, + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = CredentialKind.AccessToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = CredentialKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + Login = { RedirectEnabled = true }, + Logout = { RedirectEnabled = true } + }; - private static AuthResponseOptions Hybrid(AuthFlowType flow) - => new() + private static AuthResponseOptions Hybrid(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = CredentialKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie + }, + AccessTokenDelivery = new() { - SessionIdDelivery = new() - { - Name = "uas", - Kind = CredentialKind.Session, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.Cookie - }, - AccessTokenDelivery = new() - { - Name = "uat", - Kind = CredentialKind.AccessToken, - TokenFormat = TokenFormat.Jwt, - Mode = TokenResponseMode.Header - }, - RefreshTokenDelivery = new() - { - Name = "uar", - Kind = CredentialKind.RefreshToken, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.Cookie - }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } - }; + Name = "uat", + Kind = CredentialKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = CredentialKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie + }, + Login = { RedirectEnabled = true }, + Logout = { RedirectEnabled = true } + }; - private static AuthResponseOptions SemiHybrid(AuthFlowType flow) - => new() + private static AuthResponseOptions SemiHybrid(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = CredentialKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = CredentialKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() { - SessionIdDelivery = new() - { - Name = "uas", - Kind = CredentialKind.Session, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.None - }, - AccessTokenDelivery = new() - { - Name = "uat", - Kind = CredentialKind.AccessToken, - TokenFormat = TokenFormat.Jwt, - Mode = TokenResponseMode.Header - }, - RefreshTokenDelivery = new() - { - Name = "uar", - Kind = CredentialKind.RefreshToken, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.Header - }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } - }; + Name = "uar", + Kind = CredentialKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Header + }, + Login = { RedirectEnabled = true }, + Logout = { RedirectEnabled = true } + }; - private static AuthResponseOptions PureJwt(AuthFlowType flow) - => new() + private static AuthResponseOptions PureJwt(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() { - SessionIdDelivery = new() - { - Name = "uas", - Kind = CredentialKind.Session, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.None - }, - AccessTokenDelivery = new() - { - Name = "uat", - Kind = CredentialKind.AccessToken, - TokenFormat = TokenFormat.Jwt, - Mode = TokenResponseMode.Header - }, - RefreshTokenDelivery = new() - { - Name = "uar", - Kind = CredentialKind.RefreshToken, - TokenFormat = TokenFormat.Opaque, - Mode = TokenResponseMode.Header - }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } - }; - } + Name = "uas", + Kind = CredentialKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = CredentialKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = CredentialKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Header + }, + Login = { RedirectEnabled = true }, + Logout = { 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..8306b0e3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -0,0 +1,93 @@ +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); + + return new EffectiveAuthResponse( + bound.SessionIdDelivery, + bound.AccessTokenDelivery, + bound.RefreshTokenDelivery, + + new EffectiveLoginRedirectResponse( + bound.Login.RedirectEnabled, + bound.Login.SuccessRedirect, + bound.Login.FailureRedirect, + bound.Login.FailureQueryKey, + bound.Login.CodeQueryKey, + bound.Login.FailureCodes + ), + + new EffectiveLogoutRedirectResponse( + bound.Logout.RedirectEnabled, + bound.Logout.RedirectUrl, + bound.Logout.AllowReturnUrlOverride + ) + ); + } + + private static AuthResponseOptions BindCookies(AuthResponseOptions response, UAuthServerOptions server) + { + return new AuthResponseOptions + { + 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 + { + CredentialKind.Session => server.Cookie.Session, + CredentialKind.AccessToken => server.Cookie.AccessToken, + CredentialKind.RefreshToken => server.Cookie.RefreshToken, + _ => throw new InvalidOperationException($"Unsupported credential kind: {delivery.Kind}") + }; + + return delivery.WithCookie(cookie); + } + + private static void Validate(AuthResponseOptions 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."); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs index 5f97c238..16e75fdb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -4,52 +4,51 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class ClientProfileAuthResponseAdapter { - internal sealed class ClientProfileAuthResponseAdapter + public AuthResponseOptions Adapt(AuthResponseOptions template, UAuthClientProfile clientProfile, UAuthMode effectiveMode, EffectiveUAuthServerOptions effectiveOptions) { - public AuthResponseOptions Adapt(AuthResponseOptions template, UAuthClientProfile clientProfile, UAuthMode effectiveMode, EffectiveUAuthServerOptions effectiveOptions) - { - return new AuthResponseOptions - { - SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, CredentialKind.Session, clientProfile), - AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, CredentialKind.AccessToken, clientProfile), - RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, CredentialKind.RefreshToken, clientProfile), - - Login = template.Login, - Logout = template.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, CredentialKind kind, UAuthClientProfile clientProfile) + return new AuthResponseOptions { - if (clientProfile == UAuthClientProfile.Maui && original.Mode == TokenResponseMode.Cookie) - { - return ToHeader(original); - } + SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, CredentialKind.Session, clientProfile), + AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, CredentialKind.AccessToken, clientProfile), + RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, CredentialKind.RefreshToken, clientProfile), - if (original.TokenFormat == TokenFormat.Jwt && original.Mode == TokenResponseMode.Cookie) - { - return ToHeader(original); - } + Login = template.Login, + Logout = template.Logout + }; + } - return original; + // 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, CredentialKind kind, UAuthClientProfile clientProfile) + { + if (clientProfile == UAuthClientProfile.Maui && original.Mode == TokenResponseMode.Cookie) + { + return ToHeader(original); } - private static CredentialResponseOptions ToHeader(CredentialResponseOptions original) + if (original.TokenFormat == TokenFormat.Jwt && original.Mode == TokenResponseMode.Cookie) { - return new CredentialResponseOptions - { - TokenFormat = original.TokenFormat, - Mode = TokenResponseMode.Header, - HeaderFormat = HeaderTokenFormat.Bearer, - Name = original.Name - }; + 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 + }; } + } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultAuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultAuthResponseResolver.cs deleted file mode 100644 index 705099e0..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultAuthResponseResolver.cs +++ /dev/null @@ -1,94 +0,0 @@ -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 DefaultAuthResponseResolver : IAuthResponseResolver - { - private readonly AuthResponseOptionsModeTemplateResolver _template; - private readonly ClientProfileAuthResponseAdapter _adapter; - - public DefaultAuthResponseResolver(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); - - return new EffectiveAuthResponse( - bound.SessionIdDelivery, - bound.AccessTokenDelivery, - bound.RefreshTokenDelivery, - - new EffectiveLoginRedirectResponse( - bound.Login.RedirectEnabled, - bound.Login.SuccessRedirect, - bound.Login.FailureRedirect, - bound.Login.FailureQueryKey, - bound.Login.CodeQueryKey, - bound.Login.FailureCodes - ), - - new EffectiveLogoutRedirectResponse( - bound.Logout.RedirectEnabled, - bound.Logout.RedirectUrl, - bound.Logout.AllowReturnUrlOverride - ) - ); - } - - private static AuthResponseOptions BindCookies(AuthResponseOptions response, UAuthServerOptions server) - { - return new AuthResponseOptions - { - 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 - { - CredentialKind.Session => server.Cookie.Session, - CredentialKind.AccessToken => server.Cookie.AccessToken, - CredentialKind.RefreshToken => server.Cookie.RefreshToken, - _ => throw new InvalidOperationException($"Unsupported credential kind: {delivery.Kind}") - }; - - return delivery.WithCookie(cookie); - } - - private static void Validate(AuthResponseOptions 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."); - } - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultEffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultEffectiveAuthModeResolver.cs deleted file mode 100644 index 6c2ad30a..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/DefaultEffectiveAuthModeResolver.cs +++ /dev/null @@ -1,26 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Server.Auth -{ - internal sealed class DefaultEffectiveAuthModeResolver : IEffectiveAuthModeResolver - { - public UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType) - { - if (configuredMode.HasValue) - return configuredMode.Value; - - 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/EffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs new file mode 100644 index 00000000..35422857 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs @@ -0,0 +1,24 @@ +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(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType) + { + if (configuredMode.HasValue) + return configuredMode.Value; + + 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 index 24da3658..b0280bed 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs @@ -1,12 +1,11 @@ using CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Auth -{ - public sealed record EffectiveAuthResponse( - CredentialResponseOptions SessionIdDelivery, - CredentialResponseOptions AccessTokenDelivery, - CredentialResponseOptions RefreshTokenDelivery, - EffectiveLoginRedirectResponse Login, - EffectiveLogoutRedirectResponse Logout - ); -} +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed record EffectiveAuthResponse( + CredentialResponseOptions SessionIdDelivery, + CredentialResponseOptions AccessTokenDelivery, + CredentialResponseOptions RefreshTokenDelivery, + EffectiveLoginRedirectResponse Login, + EffectiveLogoutRedirectResponse Logout +); diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs index 75d01ad4..8408cadd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs @@ -1,14 +1,13 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Auth -{ - public sealed record EffectiveLoginRedirectResponse - ( - bool RedirectEnabled, - string SuccessPath, - string FailurePath, - string FailureQueryKey, - string CodeQueryKey, - IReadOnlyDictionary FailureCodes - ); -} +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed record EffectiveLoginRedirectResponse +( + bool RedirectEnabled, + string SuccessPath, + string FailurePath, + string FailureQueryKey, + string CodeQueryKey, + IReadOnlyDictionary FailureCodes +); diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs index 34b4a672..f042e790 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Server.Auth -{ - public sealed record EffectiveLogoutRedirectResponse - ( - bool RedirectEnabled, - string RedirectPath, - bool AllowReturnUrlOverride - ); -} +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed record EffectiveLogoutRedirectResponse +( + bool RedirectEnabled, + string RedirectPath, + bool AllowReturnUrlOverride +); diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs index 92f73fd6..080ca5a7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs @@ -2,10 +2,9 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAuthResponseResolver { - public interface IAuthResponseResolver - { - EffectiveAuthResponse Resolve(UAuthMode effectiveMode, AuthFlowType flowType, UAuthClientProfile clientProfile, EffectiveUAuthServerOptions effectiveOptions); - } + 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 index 6de3a100..2477a12d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs @@ -2,10 +2,9 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Server.Auth +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IEffectiveAuthModeResolver { - public interface IEffectiveAuthModeResolver - { - UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType); - } + UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType); } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs index 107c95d8..e2f56a2c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -3,19 +3,19 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Services; 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 UAuthAuthenticationHandler : AuthenticationHandler { private readonly ITransportCredentialResolver _transportCredentialResolver; - private readonly ISessionQueryService _sessionQuery; + private readonly ISessionValidator _sessionValidator; private readonly IDeviceContextFactory _deviceContextFactory; private readonly IClock _clock; @@ -23,15 +23,14 @@ public UAuthAuthenticationHandler( ITransportCredentialResolver transportCredentialResolver, IOptionsMonitor options, ILoggerFactory logger, - System.Text.Encodings.Web.UrlEncoder encoder, - ISystemClock clock, - ISessionQueryService sessionQuery, + UrlEncoder encoder, + ISessionValidator sessionValidator, IDeviceContextFactory deviceContextFactory, IClock uauthClock) - : base(options, logger, encoder, clock) + : base(options, logger, encoder) { _transportCredentialResolver = transportCredentialResolver; - _sessionQuery = sessionQuery; + _sessionValidator = sessionValidator; _deviceContextFactory = deviceContextFactory; _clock = uauthClock; } @@ -45,10 +44,12 @@ protected override async Task HandleAuthenticateAsync() if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) return AuthenticateResult.Fail("Invalid credential"); - var result = await _sessionQuery.ValidateSessionAsync( + var tenant = Context.GetTenant(); + + var result = await _sessionValidator.ValidateSessionAsync( new SessionValidationContext { - TenantId = credential.TenantId, + Tenant = tenant, SessionId = sessionId, Device = _deviceContextFactory.Create(credential.Device), Now = _clock.UtcNow @@ -59,46 +60,5 @@ protected override async Task HandleAuthenticateAsync() var principal = result.Claims.ToClaimsPrincipal(UAuthCookieDefaults.AuthenticationScheme); return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthCookieDefaults.AuthenticationScheme)); - - - //var principal = CreatePrincipal(result); - //var ticket = new AuthenticationTicket(principal,UAuthCookieDefaults.AuthenticationScheme); - - //return AuthenticateResult.Success(ticket); - } - - private static ClaimsPrincipal CreatePrincipal(SessionValidationResult result) - { - //var claims = new List - //{ - // new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value), - // new Claim("uauth:session_id", result.SessionId.ToString()) - //}; - - //if (!string.IsNullOrEmpty(result.TenantId)) - //{ - // claims.Add(new Claim("uauth:tenant", result.TenantId)); - //} - - //// Session claims (snapshot) - //foreach (var (key, value) in result.Claims.AsDictionary()) - //{ - // if (key == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role") - // { - // foreach (var role in value.Split(',')) - // claims.Add(new Claim(ClaimTypes.Role, role)); - // } - // else - // { - // claims.Add(new Claim(key, value)); - // } - //} - - //var identity = new ClaimsIdentity(claims, UAuthCookieDefaults.AuthenticationScheme); - //return new ClaimsPrincipal(identity); - - var identity = new ClaimsIdentity(result.Claims.ToClaims(), UAuthCookieDefaults.AuthenticationScheme); - return new ClaimsPrincipal(identity); } - } \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs index 780d68f1..db88f962 100644 --- a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs +++ b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs @@ -12,10 +12,10 @@ public static IServiceCollection Build(this UltimateAuthServerBuilder builder) 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(IUAuthUserStore<>)))) + // throw new InvalidOperationException("No credential store registered."); - if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStore)))) + if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStoreKernel)))) throw new InvalidOperationException("No session store registered."); return services; 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/LogoutResponse.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs deleted file mode 100644 index 2f804228..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Contracts -{ - public sealed record LogoutResponse - { - public bool Success { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs index a37b36a2..0accae3c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs @@ -1,12 +1,11 @@ -namespace CodeBeam.UltimateAuth.Server.Contracts +namespace CodeBeam.UltimateAuth.Server.Contracts; + +public enum RefreshTokenStatus { - public enum RefreshTokenStatus - { - Valid = 0, - Expired = 1, - Revoked = 2, - NotFound = 3, - Reused = 4, - SessionMismatch = 5 - } + Valid = 0, + Expired = 1, + Revoked = 2, + NotFound = 3, + Reused = 4, + SessionMismatch = 5 } diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs new file mode 100644 index 00000000..1d7a15d0 --- /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 PrimaryCredentialKind 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 index 6a017479..e0d6fc42 100644 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs @@ -1,20 +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; - } +namespace CodeBeam.UltimateAuth.Server.Contracts; - public static SessionRefreshResult Success(string? newSessionId = null) - => new(true, newSessionId); +public sealed class SessionRefreshResult +{ + public bool Succeeded { get; } + public string? NewSessionId { get; } - public static SessionRefreshResult Failed() - => new(false, null); + 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/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs index f90df8ea..8f445d73 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Http; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Cookies; diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs similarity index 88% rename from src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookieManager.cs rename to src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs index 5aaa3785..0bc8e1fc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookieManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Server.Cookies; -internal sealed class DefaultUAuthCookieManager : IUAuthCookieManager +internal sealed class UAuthCookieManager : IUAuthCookieManager { public void Write(HttpContext context, string name, string value, CookieOptions options) { diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs rename to src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs index 088b17fd..92523b04 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/DefaultUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Cookies; -internal sealed class DefaultUAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder +internal sealed class UAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder { public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind) { diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs index 1d0ce66b..8220ca7e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs @@ -1,70 +1,68 @@ -namespace CodeBeam.UltimateAuth.Server.Defaults +namespace CodeBeam.UltimateAuth.Server.Defaults; + +public static class UAuthActions { - public static class UAuthActions + public static class Users { - public static class Users - { - public const string Create = "users.create"; - public const string DeleteAdmin = "users.delete.admin"; - public const string ChangeStatusSelf = "users.status.change.self"; - public const string ChangeStatusAdmin = "users.status.change.admin"; - } + public const string Create = "users.create"; + 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 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 UserProfiles + { + 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 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 UserIdentifiers + { + 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 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 ActivateAdmin = "credentials.activate.admin"; - public const string BeginResetSelf = "credentials.beginreset.self"; - public const string BeginResetAdmin = "credentials.beginreset.admin"; - public const string CompleteResetSelf = "credentials.completereset.self"; - public const string CompleteResetAdmin = "credentials.completereset.admin"; - public const string DeleteAdmin = "credentials.delete.admin"; - } + public static class 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 ActivateAdmin = "credentials.activate.admin"; + public const string BeginResetSelf = "credentials.beginreset.self"; + public const string BeginResetAdmin = "credentials.beginreset.admin"; + public const string CompleteResetSelf = "credentials.completereset.self"; + public const string CompleteResetAdmin = "credentials.completereset.admin"; + public const string DeleteAdmin = "credentials.delete.admin"; + } - public static class Authorization + public static class Authorization + { + public static class Roles { - public static class Roles - { - public const string ReadSelf = "authorization.roles.read.self"; - public const string ReadAdmin = "authorization.roles.read.admin"; - public const string AssignAdmin = "authorization.roles.assign.admin"; - public const string RemoveAdmin = "authorization.roles.remove.admin"; - } + public const string ReadSelf = "authorization.roles.read.self"; + public const string ReadAdmin = "authorization.roles.read.admin"; + public const string AssignAdmin = "authorization.roles.assign.admin"; + public const string RemoveAdmin = "authorization.roles.remove.admin"; } - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs index ff635f09..2844105f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs @@ -1,14 +1,13 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IAuthorizationEndpointHandler { - 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 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); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs index 6b464500..72b3df38 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ILoginEndpointHandler { - public interface ILoginEndpointHandler - { - Task LoginAsync(HttpContext ctx); - } + Task LoginAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs index 3185a333..424560f2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ILogoutEndpointHandler { - public interface ILogoutEndpointHandler - { - Task LogoutAsync(HttpContext ctx); - } + Task LogoutAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs index f26dc4a7..547dcf9b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs @@ -1,21 +1,20 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IPkceEndpointHandler { - 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); + /// + /// 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); - } + /// + /// Completes the PKCE flow. + /// Atomically validates and consumes the authorization code, + /// then issues a session or token. + /// + Task CompleteAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs index 4de1bae8..8dfe0c20 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IReauthEndpointHandler { - public interface IReauthEndpointHandler - { - Task ReauthAsync(HttpContext ctx); - } + Task ReauthAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs index b79cd0d7..70cfc80f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IRefreshEndpointHandler { - public interface IRefreshEndpointHandler - { - Task RefreshAsync(HttpContext ctx); - } + Task RefreshAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs index 1d5c9288..a4bcd598 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ISessionManagementHandler { - public interface ISessionManagementHandler - { - Task GetCurrentSessionAsync(HttpContext ctx); - Task GetAllSessionsAsync(HttpContext ctx); - Task RevokeSessionAsync(string sessionId, HttpContext ctx); - Task RevokeAllAsync(HttpContext ctx); - } + Task GetCurrentSessionAsync(HttpContext ctx); + Task GetAllSessionsAsync(HttpContext ctx); + Task RevokeSessionAsync(string sessionId, HttpContext ctx); + Task RevokeAllAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs index e69a1e55..0056c86f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ITokenEndpointHandler { - public interface ITokenEndpointHandler - { - Task GetTokenAsync(HttpContext ctx); - Task RefreshTokenAsync(HttpContext ctx); - Task IntrospectAsync(HttpContext ctx); - Task RevokeAsync(HttpContext ctx); - } + 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/IUserInfoEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs index 54c0eae6..04a1a23c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs @@ -1,11 +1,10 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IUserInfoEndpointHandler { - public interface IUserInfoEndpointHandler - { - Task GetUserInfoAsync(HttpContext ctx); - Task GetPermissionsAsync(HttpContext ctx); - Task CheckPermissionAsync(HttpContext ctx); - } + 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 index 94a395ab..d97aee7e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IValidateEndpointHandler.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IValidateEndpointHandler { - public interface IValidateEndpointHandler - { - Task ValidateAsync(HttpContext context, CancellationToken ct = default); - } + 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..3050581d --- /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..d6d79662 --- /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..6d515515 --- /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/DefaultLogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs deleted file mode 100644 index 9a99f0ef..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs +++ /dev/null @@ -1,75 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Contracts; -using CodeBeam.UltimateAuth.Server.Cookies; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Server.Services; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public sealed class DefaultLogoutEndpointHandler : ILogoutEndpointHandler - { - private readonly IAuthFlowContextAccessor _authContext; - private readonly IUAuthFlowService _flow; - private readonly IClock _clock; - private readonly IUAuthCookieManager _cookieManager; - private readonly AuthRedirectResolver _redirectResolver; - - public DefaultLogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, AuthRedirectResolver redirectResolver) - { - _authContext = authContext; - _flow = flow; - _clock = clock; - _cookieManager = cookieManager; - _redirectResolver = redirectResolver; - } - - public async Task LogoutAsync(HttpContext ctx) - { - var auth = _authContext.Current; - - if (auth.Session is SessionSecurityContext session) - { - var request = new LogoutRequest - { - TenantId = auth.TenantId, - SessionId = session.SessionId, - At = _clock.UtcNow, - }; - - await _flow.LogoutAsync(request, ctx.RequestAborted); - } - - DeleteIfCookie(ctx, auth.Response.SessionIdDelivery); - DeleteIfCookie(ctx, auth.Response.RefreshTokenDelivery); - DeleteIfCookie(ctx, auth.Response.AccessTokenDelivery); - - if (auth.Response.Logout.RedirectEnabled) - { - var redirectUrl = _redirectResolver.ResolveRedirect(ctx, auth.Response.Logout.RedirectPath); - return Results.Redirect(redirectUrl); - } - - return Results.Ok(new LogoutResponse - { - Success = true - }); - } - - 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/DefaultRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs deleted file mode 100644 index 83b703ad..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultRefreshEndpointHandler.cs +++ /dev/null @@ -1,91 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Abstractions; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Services; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public sealed class DefaultRefreshEndpointHandler : 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 DefaultRefreshEndpointHandler( - 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.Session is not SessionSecurityContext session) - { - //_logger.LogDebug("Refresh called without active session."); - return Results.Ok(RefreshOutcome.None); - } - - var request = new RefreshFlowRequest - { - SessionId = session.SessionId, - RefreshToken = _refreshTokenResolver.Resolve(ctx), - Device = flow.Device, - Now = DateTimeOffset.UtcNow - }; - - var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted); - - if (!result.Succeeded) - { - WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); - return Results.Unauthorized(); - } - - var primary = _refreshPolicy.SelectPrimary(flow, request, result); - - if (primary == CredentialKind.Session && result.SessionId is not null) - { - _credentialWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); - } - else if (primary == CredentialKind.AccessToken && result.AccessToken is not null) - { - _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); - } - - if (_refreshPolicy.WriteRefreshToken(flow) && result.RefreshToken is not null) - { - _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); - } - - WriteRefreshHeader(ctx, flow, result.Outcome); - return Results.NoContent(); - } - - private void WriteRefreshHeader(HttpContext ctx, AuthFlowContext flow, RefreshOutcome outcome) - { - if (!flow.OriginalOptions.Diagnostics.EnableRefreshHeaders) - return; - - _refreshWriter.Write(ctx, outcome); - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs deleted file mode 100644 index f47f8a6a..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs +++ /dev/null @@ -1,97 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Services; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - internal sealed class DefaultValidateEndpointHandler : IValidateEndpointHandler - { - private readonly IAuthFlowContextAccessor _authContext; - private readonly IFlowCredentialResolver _credentialResolver; - private readonly ISessionQueryService _sessionValidator; - private readonly IClock _clock; - - public DefaultValidateEndpointHandler( - IAuthFlowContextAccessor authContext, - IFlowCredentialResolver credentialResolver, - ISessionQueryService sessionValidator, - IClock clock) - { - _authContext = authContext; - _credentialResolver = credentialResolver; - _sessionValidator = sessionValidator; - _clock = clock; - } - - public async Task ValidateAsync(HttpContext context, CancellationToken ct = default) - { - var auth = _authContext.Current; - var credential = _credentialResolver.Resolve(context, auth.Response); - - if (credential is null) - { - return Results.Json( - new AuthValidationResult - { - IsValid = false, - State = "missing" - }, - statusCode: StatusCodes.Status401Unauthorized - ); - } - - if (credential.Kind == PrimaryCredentialKind.Stateful) - { - if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) - { - return Results.Json( - new AuthValidationResult - { - IsValid = false, - State = "invalid" - }, - statusCode: StatusCodes.Status401Unauthorized - ); - } - - var result = await _sessionValidator.ValidateSessionAsync( - new SessionValidationContext - { - TenantId = credential.TenantId, - SessionId = sessionId, - Now = _clock.UtcNow, - Device = auth.Device - }, - ct); - - return Results.Ok(new AuthValidationResult - { - IsValid = result.IsValid, - State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant(), - Snapshot = new AuthStateSnapshot - { - UserId = result.UserKey, - TenantId = result.TenantId, - Claims = result.Claims, - AuthenticatedAt = _clock.UtcNow, - } - }); - } - - // Stateless (JWT / Opaque) – 0.0.1 no support yet - return Results.Json( - new AuthValidationResult - { - IsValid = false, - State = "unsupported" - }, - statusCode: StatusCodes.Status401Unauthorized - ); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs rename to src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index bb215f45..5b5361e9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -4,7 +4,6 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; 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; @@ -12,7 +11,7 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; -public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler +public sealed class LoginEndpointHandler : ILoginEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IUAuthFlowService _flowService; @@ -20,7 +19,7 @@ public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler private readonly ICredentialResponseWriter _credentialResponseWriter; private readonly AuthRedirectResolver _redirectResolver; - public DefaultLoginEndpointHandler( + public LoginEndpointHandler( IAuthFlowContextAccessor authFlow, IUAuthFlowService flowService, IClock clock, @@ -57,7 +56,7 @@ public async Task LoginAsync(HttpContext ctx) { Identifier = identifier, Secret = secret, - TenantId = authFlow.TenantId, + Tenant = authFlow.Tenant, At = _clock.UtcNow, Device = authFlow.Device, RequestTokens = shouldIssueTokens diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs deleted file mode 100644 index 7b769f14..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandlerBridge.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - internal sealed class LoginEndpointHandlerBridge : ILoginEndpointHandler - { - private readonly DefaultLoginEndpointHandler _inner; - - public LoginEndpointHandlerBridge(DefaultLoginEndpointHandler inner) - { - _inner = inner; - } - - public Task LoginAsync(HttpContext ctx) => _inner.LoginAsync(ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs new file mode 100644 index 00000000..d99f9e1e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -0,0 +1,73 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Cookies; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public sealed class LogoutEndpointHandler : ILogoutEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authContext; + private readonly IUAuthFlowService _flow; + private readonly IClock _clock; + private readonly IUAuthCookieManager _cookieManager; + private readonly AuthRedirectResolver _redirectResolver; + + public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, AuthRedirectResolver redirectResolver) + { + _authContext = authContext; + _flow = flow; + _clock = clock; + _cookieManager = cookieManager; + _redirectResolver = redirectResolver; + } + + public async Task LogoutAsync(HttpContext ctx) + { + var auth = _authContext.Current; + + if (auth.Session is SessionSecurityContext session) + { + var request = new LogoutRequest + { + Tenant = auth.Tenant, + SessionId = session.SessionId, + At = _clock.UtcNow, + }; + + await _flow.LogoutAsync(request, ctx.RequestAborted); + } + + DeleteIfCookie(ctx, auth.Response.SessionIdDelivery); + DeleteIfCookie(ctx, auth.Response.RefreshTokenDelivery); + DeleteIfCookie(ctx, auth.Response.AccessTokenDelivery); + + if (auth.Response.Logout.RedirectEnabled) + { + var redirectUrl = _redirectResolver.ResolveRedirect(ctx, auth.Response.Logout.RedirectPath); + return Results.Redirect(redirectUrl); + } + + return Results.Ok(new LogoutResponse + { + Success = true + }); + } + + 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/LogoutEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs deleted file mode 100644 index f35f05cc..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandlerBridge.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - internal sealed class LogoutEndpointHandlerBridge : ILogoutEndpointHandler - { - private readonly DefaultLogoutEndpointHandler _inner; - - public LogoutEndpointHandlerBridge(DefaultLogoutEndpointHandler inner) - { - _inner = inner; - } - - public Task LogoutAsync(HttpContext ctx) - => _inner.LogoutAsync(ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs similarity index 89% rename from src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs rename to src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index a4db8cfa..f53a6bb2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultPkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; @@ -12,7 +13,7 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; -internal sealed class DefaultPkceEndpointHandler : IPkceEndpointHandler +internal sealed class PkceEndpointHandler : IPkceEndpointHandler { private readonly IAuthFlowContextAccessor _authContext; private readonly IUAuthFlowService _flow; @@ -23,7 +24,7 @@ internal sealed class DefaultPkceEndpointHandler : IPkceEndpointHandler private readonly ICredentialResponseWriter _credentialResponseWriter; private readonly AuthRedirectResolver _redirectResolver; - public DefaultPkceEndpointHandler( + public PkceEndpointHandler( IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IAuthStore authStore, @@ -64,7 +65,7 @@ public async Task AuthorizeAsync(HttpContext ctx) var snapshot = new PkceContextSnapshot( clientProfile: authContext.ClientProfile, - tenantId: authContext.TenantId, + tenant: authContext.Tenant, redirectUri: request.RedirectUri, deviceId: string.Empty // TODO: Fix here with device binding ); @@ -112,7 +113,7 @@ public async Task CompleteAsync(HttpContext ctx) var validation = _validator.Validate(artifact, request.CodeVerifier, new PkceContextSnapshot( clientProfile: authContext.ClientProfile, - tenantId: authContext.TenantId, + tenant: authContext.Tenant, redirectUri: null, deviceId: string.Empty), _clock.UtcNow); @@ -120,14 +121,14 @@ public async Task CompleteAsync(HttpContext ctx) if (!validation.Success) { artifact.RegisterAttempt(); - return RedirectToLoginWithError(ctx, authContext, "invalid"); + return await RedirectToLoginWithErrorAsync(ctx, authContext, "invalid"); } var loginRequest = new LoginRequest { Identifier = request.Identifier, Secret = request.Secret, - TenantId = authContext.TenantId, + Tenant = authContext.Tenant, At = _clock.UtcNow, Device = authContext.Device, RequestTokens = authContext.AllowsTokenIssuance @@ -141,7 +142,7 @@ public async Task CompleteAsync(HttpContext ctx) var result = await _flow.LoginAsync(authContext, execution, loginRequest, ctx.RequestAborted); if (!result.IsSuccess) - return RedirectToLoginWithError(ctx, authContext, "invalid"); + return await RedirectToLoginWithErrorAsync(ctx, authContext, "invalid"); if (result.SessionId is not null) { @@ -224,26 +225,27 @@ public async Task CompleteAsync(HttpContext ctx) return null; } - private IResult RedirectToLoginWithError(HttpContext ctx, AuthFlowContext auth, string error) + private async Task RedirectToLoginWithErrorAsync(HttpContext ctx, AuthFlowContext auth, string error) { var basePath = auth.OriginalOptions.Hub.LoginPath ?? "/login"; - var hubKey = ctx.Request.Query["hub"].ToString(); if (!string.IsNullOrWhiteSpace(hubKey)) { var key = new AuthArtifactKey(hubKey); - var artifact = _authStore.GetAsync(key, ctx.RequestAborted).Result; + var artifact = await _authStore.GetAsync(key, ctx.RequestAborted); if (artifact is HubFlowArtifact hub) { hub.MarkCompleted(); - _authStore.StoreAsync(key, hub, ctx.RequestAborted); + await _authStore.StoreAsync(key, hub, ctx.RequestAborted); } - return Results.Redirect($"{basePath}?hub={Uri.EscapeDataString(hubKey)}&__uauth_error={Uri.EscapeDataString(error)}"); + + return Results.Redirect( + $"{basePath}?hub={Uri.EscapeDataString(hubKey)}&__uauth_error={Uri.EscapeDataString(error)}"); } - return Results.Redirect($"{basePath}?__uauth_error={Uri.EscapeDataString(error)}"); + return Results.Redirect( + $"{basePath}?__uauth_error={Uri.EscapeDataString(error)}"); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs deleted file mode 100644 index 8f8f803b..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandlerBridge.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - internal sealed class PkceEndpointHandlerBridge : IPkceEndpointHandler - { - private readonly DefaultPkceEndpointHandler _inner; - - public PkceEndpointHandlerBridge(DefaultPkceEndpointHandler 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/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs new file mode 100644 index 00000000..4f97e151 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -0,0 +1,90 @@ +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.Session is not SessionSecurityContext session) + { + //_logger.LogDebug("Refresh called without active session."); + return Results.Ok(RefreshOutcome.None); + } + + var request = new RefreshFlowRequest + { + SessionId = session.SessionId, + RefreshToken = _refreshTokenResolver.Resolve(ctx), + Device = flow.Device, + Now = DateTimeOffset.UtcNow + }; + + var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted); + + if (!result.Succeeded) + { + WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); + return Results.Unauthorized(); + } + + var primary = _refreshPolicy.SelectPrimary(flow, request, result); + + if (primary == CredentialKind.Session && result.SessionId is not null) + { + _credentialWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + } + else if (primary == CredentialKind.AccessToken && result.AccessToken is not null) + { + _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + } + + if (_refreshPolicy.WriteRefreshToken(flow) && result.RefreshToken is not null) + { + _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + } + + WriteRefreshHeader(ctx, flow, result.Outcome); + return Results.NoContent(); + } + + private void WriteRefreshHeader(HttpContext ctx, AuthFlowContext flow, RefreshOutcome outcome) + { + if (!flow.OriginalOptions.Diagnostics.EnableRefreshHeaders) + return; + + _refreshWriter.Write(ctx, outcome); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs deleted file mode 100644 index 22e776fa..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandlerBridge.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - internal sealed class RefreshEndpointHandlerBridge : IRefreshEndpointHandler - { - private readonly DefaultRefreshEndpointHandler _inner; - - public RefreshEndpointHandlerBridge(DefaultRefreshEndpointHandler inner) - { - _inner = inner; - } - - public Task RefreshAsync(HttpContext ctx) => _inner.RefreshAsync(ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaults.cs deleted file mode 100644 index 3eea6f60..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointDefaults.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server -{ - /// - /// Represents which endpoint groups are enabled by default - /// for a given authentication mode. - /// - public sealed class UAuthEndpointDefaults - { - public bool Login { get; init; } - public bool Pkce { get; init; } - public bool Token { get; init; } - public bool Session { get; init; } - public bool UserInfo { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 2aa483c8..546358de 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -5,255 +5,247 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using static CodeBeam.UltimateAuth.Server.Defaults.UAuthActions; -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public interface IAuthEndpointRegistrar - { - void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options); - } +namespace CodeBeam.UltimateAuth.Server.Endpoints; - // TODO: Add Scalar/Swagger integration - // TODO: Add endpoint based guards - public class UAuthEndpointRegistrar : IAuthEndpointRegistrar +// TODO: Add Scalar/Swagger integration +// TODO: Add endpoint based guards +public class UAuthEndpointRegistrar : IAuthEndpointRegistrar +{ + public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) { - public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) - { - // Default base: /auth - string basePrefix = options.RoutePrefix.TrimStart('/'); - bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute; - - RouteGroupBuilder group = useRouteTenant - ? rootGroup.MapGroup("/{tenant}/" + basePrefix) - : rootGroup.MapGroup("/" + basePrefix); + // Default base: /auth + string basePrefix = options.RoutePrefix.TrimStart('/'); + bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute; - group.AddEndpointFilter(); + RouteGroupBuilder group = useRouteTenant + ? rootGroup.MapGroup("/{tenant}/" + basePrefix) + : rootGroup.MapGroup("/" + basePrefix); - if (options.EnableLoginEndpoints != false) - { - group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) - => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + group.AddEndpointFilter(); - group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) - => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)); + if (options.EnableLoginEndpoints != false) + { + group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) + => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); - group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) + => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)); - group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) - => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); + group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); - group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) - => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); - } + group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) + => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); - if (options.EnablePkceEndpoints != false) - { - var pkce = group.MapGroup("/pkce"); + group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) + => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); + } - pkce.MapPost("/authorize", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.AuthorizeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + if (options.EnablePkceEndpoints != false) + { + var pkce = group.MapGroup("/pkce"); - pkce.MapPost("/complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); - } + pkce.MapPost("/authorize", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) + => await h.AuthorizeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); - if (options.EnableTokenEndpoints != false) - { - var token = group.MapGroup(""); + pkce.MapPost("/complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) + => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + } - token.MapPost("/token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.GetTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IssueToken)); + if (options.EnableTokenEndpoints != false) + { + var token = group.MapGroup(""); - token.MapPost("/refresh-token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.RefreshTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshToken)); + token.MapPost("/token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + => await h.GetTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IssueToken)); - token.MapPost("/introspect", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.IntrospectAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IntrospectToken)); + token.MapPost("/refresh-token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + => await h.RefreshTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshToken)); - token.MapPost("/revoke", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); - } + token.MapPost("/introspect", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + => await h.IntrospectAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IntrospectToken)); - if (options.EnableSessionEndpoints != false) - { - var session = group.MapGroup("/session"); + token.MapPost("/revoke", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); + } - session.MapPost("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetCurrentSessionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + if (options.EnableSessionEndpoints != false) + { + var session = group.MapGroup("/session"); - session.MapPost("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetAllSessionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + session.MapPost("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) + => await h.GetCurrentSessionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); - session.MapPost("/revoke/{sessionId}", async ([FromServices] ISessionManagementHandler h, string sessionId, HttpContext ctx) - => await h.RevokeSessionAsync(sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + session.MapPost("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) + => await h.GetAllSessionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); - session.MapPost("/revoke-all", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); - } + session.MapPost("/revoke/{sessionId}", async ([FromServices] ISessionManagementHandler h, string sessionId, HttpContext ctx) + => await h.RevokeSessionAsync(sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); - var user = group.MapGroup(""); - var users = group.MapGroup("/users"); - var adminUsers = group.MapGroup("/admin/users"); + session.MapPost("/revoke-all", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) + => await h.RevokeAllAsync(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)); + var user = group.MapGroup(""); + var users = group.MapGroup("/users"); + var adminUsers = group.MapGroup("/admin/users"); - // user.MapPost("/permissions", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - // => await h.GetPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); + //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/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); - //} + // user.MapPost("/permissions", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.GetPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); - if (options.EnableUserLifecycleEndpoints != false) - { - users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + // user.MapPost("/permissions/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); + //} - users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + if (options.EnableUserLifecycleEndpoints != false) + { + users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - // Post is intended for Auth - adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - } + adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - if (options.EnableUserProfileEndpoints != false) - { - users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + // Post is intended for Auth + adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + } - users.MapPost("/me/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (options.EnableUserProfileEndpoints != false) + { + users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + users.MapPost("/me/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - } + adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - if (options.EnableUserIdentifierEndpoints != false) - { - users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + } - users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + if (options.EnableUserIdentifierEndpoints != false) + { + users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + users.MapPost("/me/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + users.MapPost("/me/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + users.MapPost("/me/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + users.MapPost("/me/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + users.MapPost("/me/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - } + adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - if (options.EnableCredentialsEndpoints != false) - { - var credentials = group.MapGroup("/credentials"); - var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); + adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + } - credentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.GetAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (options.EnableCredentialsEndpoints != false) + { + var credentials = group.MapGroup("/credentials"); + var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); - credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.GetAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/change", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.RevokeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/{type}/change", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.BeginResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.RevokeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.CompleteResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.BeginResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.CompleteResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetAllAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetAllAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.RevokeAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.ActivateAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.RevokeAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.BeginResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.ActivateAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.CompleteResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.BeginResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - } + adminCredentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.CompleteResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - if (options.EnableAuthorizationEndpoints != false) - { - var authz = group.MapGroup("/authorization"); - var adminAuthz = group.MapGroup("/admin/authorization"); + adminCredentials.MapPost("/{type}/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + } - authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + if (options.EnableAuthorizationEndpoints != false) + { + var authz = group.MapGroup("/authorization"); + var adminAuthz = group.MapGroup("/admin/authorization"); - authz.MapPost("/users/me/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + authz.MapPost("/users/me/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - adminAuthz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - adminAuthz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - } + adminAuthz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + adminAuthz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs new file mode 100644 index 00000000..d76a8064 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -0,0 +1,109 @@ +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 IFlowCredentialResolver _credentialResolver; + private readonly ISessionValidator _sessionValidator; + private readonly IClock _clock; + + public ValidateEndpointHandler( + IAuthFlowContextAccessor authContext, + IFlowCredentialResolver credentialResolver, + ISessionValidator sessionValidator, + IClock clock) + { + _authContext = authContext; + _credentialResolver = credentialResolver; + _sessionValidator = sessionValidator; + _clock = clock; + } + + public async Task ValidateAsync(HttpContext context, CancellationToken ct = default) + { + var auth = _authContext.Current; + var credential = _credentialResolver.Resolve(context, auth.Response); + + if (credential is null) + { + return Results.Json( + new AuthValidationResult + { + IsValid = false, + State = "missing" + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + if (credential.Kind == PrimaryCredentialKind.Stateful) + { + if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) + { + return Results.Json( + new AuthValidationResult + { + IsValid = false, + State = "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 + { + IsValid = false, + State = "invalid" + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + return Results.Ok(new AuthValidationResult + { + IsValid = result.IsValid, + State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant(), + Snapshot = new AuthStateSnapshot + { + UserKey = userKey, + Tenant = result.Tenant, + Claims = result.Claims, + AuthenticatedAt = _clock.UtcNow, + } + }); + } + + // Stateless (JWT / Opaque) – 0.0.1 no support yet + return Results.Json( + new AuthValidationResult + { + IsValid = false, + State = "unsupported" + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs deleted file mode 100644 index f9a2be56..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandlerBridge.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - internal sealed class ValidateEndpointHandlerBridge : IValidateEndpointHandler - { - private readonly DefaultValidateEndpointHandler _inner; - - public ValidateEndpointHandlerBridge(DefaultValidateEndpointHandler inner) - { - _inner = inner; - } - - public Task ValidateAsync(HttpContext context, CancellationToken ct = default) => _inner.ValidateAsync(context, ct); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs index fb276f16..e48c09d3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs @@ -2,39 +2,37 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class AuthFlowContextExtensions { - public static class AuthFlowContextExtensions + public static AuthContext ToAuthContext(this AuthFlowContext flow, DateTimeOffset now) { - public static AuthContext ToAuthContext(this AuthFlowContext flow, DateTimeOffset now) - { - return new AuthContext - { - TenantId = flow.TenantId, - Operation = flow.FlowType.ToAuthOperation(), - Mode = flow.EffectiveMode, - At = now, - Device = flow.Device, - Session = flow.Session - }; - } - - public static AuthFlowContext WithClientProfile(this AuthFlowContext flow, UAuthClientProfile profile) + return new AuthContext { - return new AuthFlowContext( - flow.FlowType, - profile, - flow.EffectiveMode, - flow.Device, - flow.TenantId, - flow.IsAuthenticated, - flow.UserKey, - flow.Session, - flow.OriginalOptions, - flow.EffectiveOptions, - flow.Response, - flow.PrimaryTokenKind); - } + Tenant = flow.Tenant, + Operation = flow.FlowType.ToAuthOperation(), + Mode = flow.EffectiveMode, + At = now, + Device = flow.Device, + Session = flow.Session + }; + } + public static AuthFlowContext WithClientProfile(this AuthFlowContext flow, UAuthClientProfile profile) + { + return new AuthFlowContext( + flow.FlowType, + profile, + flow.EffectiveMode, + flow.Device, + flow.Tenant, + flow.IsAuthenticated, + flow.UserKey, + flow.Session, + flow.OriginalOptions, + flow.EffectiveOptions, + flow.Response, + flow.PrimaryTokenKind); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs index ea1803c8..a61260b8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class AuthFlowTypeExtensions { - public static class AuthFlowTypeExtensions - { - public static AuthOperation ToAuthOperation(this AuthFlowType flowType) + public static AuthOperation ToAuthOperation(this AuthFlowType flowType) => flowType switch { AuthFlowType.Login => AuthOperation.Login, @@ -29,5 +29,4 @@ public static AuthOperation ToAuthOperation(this AuthFlowType flowType) _ => 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 index bfdbee3d..d0e17f40 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs @@ -1,14 +1,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using System.Security.Claims; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class ClaimsSnapshotExtensions { - public static class ClaimsSnapshotExtensions - { - public static IReadOnlyCollection AsClaims( - this ClaimsSnapshot snapshot) - => snapshot.AsDictionary() - .Select(kv => new Claim(kv.Key, kv.Value)) - .ToArray(); - } + 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/DeviceExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs index e6fbcf67..de1bd560 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs @@ -3,16 +3,13 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class DeviceExtensions { - public static class DeviceExtensions + public static DeviceInfo GetDevice(this HttpContext context) { - public static DeviceInfo GetDevice(this HttpContext context) - { - var resolver = context.RequestServices - .GetRequiredService(); - - return resolver.Resolve(context); - } + var resolver = context.RequestServices.GetRequiredService(); + return resolver.Resolve(context); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs index a501a1b4..f27f3828 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs @@ -5,27 +5,21 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class EndpointRouteBuilderExtensions { - public static class EndpointRouteBuilderExtensions + public static IEndpointRouteBuilder MapUAuthEndpoints(this IEndpointRouteBuilder endpoints) { - public static IEndpointRouteBuilder MapUAuthEndpoints(this IEndpointRouteBuilder endpoints) - { - using var scope = endpoints.ServiceProvider.CreateScope(); - - var registrar = scope.ServiceProvider - .GetRequiredService(); - - var options = scope.ServiceProvider - .GetRequiredService>() - .Value; + using var scope = endpoints.ServiceProvider.CreateScope(); + var registrar = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>().Value; - // Root group ("/") - var rootGroup = endpoints.MapGroup(""); + // Root group ("/") + var rootGroup = endpoints.MapGroup(""); - registrar.MapEndpoints(rootGroup, options); + registrar.MapEndpoints(rootGroup, options); - return endpoints; - } + return endpoints; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs index 4b2c9b13..c429e7c5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs @@ -1,26 +1,25 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextJsonExtensions { - public static class HttpContextJsonExtensions - { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) - { - if (!ctx.Request.HasJsonContentType()) - throw new InvalidOperationException("Request content type must be application/json."); + public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) + { + if (!ctx.Request.HasJsonContentType()) + throw new InvalidOperationException("Request content type must be application/json."); - if (ctx.Request.Body is null) - throw new InvalidOperationException("Request body is empty."); + if (ctx.Request.Body is null) + throw new InvalidOperationException("Request body is empty."); - var result = await JsonSerializer.DeserializeAsync(ctx.Request.Body, JsonOptions, ct); + var result = await JsonSerializer.DeserializeAsync(ctx.Request.Body, JsonOptions, ct); - if (result is null) - throw new InvalidOperationException("Request body could not be deserialized."); + if (result is null) + throw new InvalidOperationException("Request body could not be deserialized."); - return result; - } + return result; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs index 4208268f..51641fd5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs @@ -2,20 +2,17 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextSessionExtensions { - public static class HttpContextSessionExtensions + public static SessionContext GetSessionContext(this HttpContext context) { - public static SessionContext GetSessionContext(this HttpContext context) + if (context.Items.TryGetValue(SessionContextItemKeys.SessionContext, out var value) && value is SessionContext session) { - if (context.Items.TryGetValue(SessionContextItemKeys.SessionContext, out var value) - && value is SessionContext session) - { - return session; - } - - return SessionContext.Anonymous(); + return session; } - } + return SessionContext.Anonymous(); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs index 6a74f8a3..bd1c749d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs @@ -2,26 +2,17 @@ using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextTenantExtensions { - public static class HttpContextTenantExtensions + public static TenantKey GetTenant(this HttpContext context) { - public static string? GetTenantId(this HttpContext ctx) + if (!context.Items.TryGetValue(TenantMiddleware.TenantContextKey, out var value) || value is not UAuthTenantContext tenantCtx) { - return ctx.GetTenantContext().TenantId; + throw new InvalidOperationException("TenantContext is missing. TenantMiddleware must run before authentication."); } - public static UAuthTenantContext GetTenantContext(this HttpContext ctx) - { - if (ctx.Items.TryGetValue( - TenantMiddleware.TenantContextKey, - out var value) - && value is UAuthTenantContext tenant) - { - return tenant; - } - - return UAuthTenantContext.NotResolved(); - } + return tenantCtx.Tenant; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs index ed598241..33dbd730 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs @@ -3,18 +3,17 @@ using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextUserExtensions { - public static class HttpContextUserExtensions + public static AuthUserSnapshot GetUserContext(this HttpContext ctx) { - public static AuthUserSnapshot GetUserContext(this HttpContext ctx) + if (ctx.Items.TryGetValue(UserMiddleware.UserContextKey, out var value) && value is AuthUserSnapshot user) { - if (ctx.Items.TryGetValue(UserMiddleware.UserContextKey, out var value) && value is AuthUserSnapshot user) - { - return user; - } - - return AuthUserSnapshot.Anonymous(); + 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..dc44adab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,307 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +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; +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.Cookies; +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.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using Microsoft.Extensions.Configuration; +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) + { + services.AddUltimateAuth(); + AddUsersInternal(services); + AddCredentialsInternal(services); + AddAuthorizationInternal(services); + AddUltimateAuthPolicies(services); + return services.AddUltimateAuthServerInternal(); + } + + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) + { + services.AddUltimateAuth(configuration); + AddUsersInternal(services); + AddCredentialsInternal(services); + AddAuthorizationInternal(services); + AddUltimateAuthPolicies(services); + services.Configure(configuration.GetSection("UltimateAuth:Server")); + + return services.AddUltimateAuthServerInternal(); + } + + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action configure) + { + services.AddUltimateAuth(); + AddUsersInternal(services); + AddCredentialsInternal(services); + AddAuthorizationInternal(services); + AddUltimateAuthPolicies(services); + services.Configure(configure); + + return services.AddUltimateAuthServerInternal(); + } + + private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(sp => + { + var keyProvider = sp.GetRequiredService(); + var key = keyProvider.Resolve(null); + + return new HmacSha256TokenHasher(((SymmetricSecurityKey)key.Key).Key); + }); + + + // ----------------------------- + // OPTIONS VALIDATION + // ----------------------------- + services.TryAddEnumerable(ServiceDescriptor.Singleton, UAuthServerOptionsValidator>()); + + // 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(); + + // Public resolver + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddSingleton(); + + // TODO: Allow custom cookie manager via options + //services.AddSingleton(); + //if (options.CustomCookieManagerType is not null) + //{ + // services.AddSingleton(typeof(IUAuthSessionCookieManager), options.CustomCookieManagerType); + //} + //else + //{ + // services.AddSingleton(); + //} + + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddHttpContextAccessor(); + services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(typeof(ILoginOrchestrator<>), typeof(LoginOrchestrator<>)); + 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.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.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddScoped(); + + services.TryAddSingleton(); + services.TryAddScoped(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddScoped(); + + services.TryAddScoped(); + + // ----------------------------- + // ENDPOINTS + // ----------------------------- + services.TryAddScoped(); + services.TryAddSingleton(); + + services.TryAddScoped>(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped>(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped>(); + services.TryAddScoped(); + + return services; + } + + internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) + { + if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) + throw new InvalidOperationException("UltimateAuth policies already registered."); + + var registry = new AccessPolicyRegistry(); + + DefaultPolicySet.Register(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); + }); + + services.TryAddScoped(); + + return services; + } + + // ========================= + // Users (Framework-Required) + // ========================= + internal static IServiceCollection AddUsersInternal(IServiceCollection services) + { + services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + 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 AddUAuthServerInfrastructure(this IServiceCollection services) + { + // Flow orchestration + services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); + + // Issuers + services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); + services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); + + // Endpoints + services.TryAddSingleton(); + + // Cookie management (default) + services.TryAddSingleton(); + + return services; + } + +} + +internal sealed class NullTenantResolver : ITenantIdResolver +{ + public Task ResolveTenantIdAsync(TenantResolutionContext context) => Task.FromResult(null); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/TenantResolutionContextExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/TenantResolutionContextExtensions.cs deleted file mode 100644 index 11a98d17..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/TenantResolutionContextExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -//using Microsoft.AspNetCore.Http; -//using CodeBeam.UltimateAuth.Core.MultiTenancy; - -//namespace CodeBeam.UltimateAuth.Server.MultiTenancy -//{ -// public static class TenantResolutionContextExtensions -// { -// public static TenantResolutionContext FromHttpContext(this HttpContext ctx) -// { -// var headers = ctx.Request.Headers -// .ToDictionary( -// h => h.Key, -// h => h.Value.ToString(), -// StringComparer.OrdinalIgnoreCase); - -// return new TenantResolutionContext -// { -// Headers = headers, -// Host = ctx.Request.Host.Host, -// Path = ctx.Request.Path.Value, -// RawContext = ctx -// }; -// } -// } -//} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index 9981235b..5d3cf175 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -1,18 +1,16 @@ using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Builder; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class UltimateAuthApplicationBuilderExtensions { - public static class UltimateAuthApplicationBuilderExtensions + public static IApplicationBuilder UseUltimateAuthServer(this IApplicationBuilder app) { - public static IApplicationBuilder UseUltimateAuthServer(this IApplicationBuilder app) - { - app.UseMiddleware(); - app.UseMiddleware(); - app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); - return app; - } + return app; } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs index 61ad1d3f..07c268da 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Extensions +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class UAuthServerOptionsExtensions { - public static class UAuthServerOptionsExtensions + public static void ConfigureMode(this UAuthServerOptions options, UAuthMode mode, Action configure) { - public static void ConfigureMode(this UAuthServerOptions options, UAuthMode mode, Action configure) - { - options.ModeConfigurations[mode] = configure; - } + options.ModeConfigurations[mode] = configure; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs deleted file mode 100644 index 494af80d..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ /dev/null @@ -1,372 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; -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; -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.Cookies; -using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Infrastructure.Hub; -using CodeBeam.UltimateAuth.Server.Infrastructure.Session; -using CodeBeam.UltimateAuth.Server.Issuers; -using CodeBeam.UltimateAuth.Server.Login; -using CodeBeam.UltimateAuth.Server.Login.Orchestrators; -using CodeBeam.UltimateAuth.Server.MultiTenancy; -using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Server.Services; -using CodeBeam.UltimateAuth.Server.Stores; -using Microsoft.Extensions.Configuration; -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 UAuthServerServiceCollectionExtensions - { - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services) - { - services.AddUltimateAuth(); - AddUsersInternal(services); - AddCredentialsInternal(services); - AddUltimateAuthPolicies(services); - return services.AddUltimateAuthServerInternal(); - } - - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) - { - services.AddUltimateAuth(configuration); - AddUsersInternal(services); - AddCredentialsInternal(services); - AddUltimateAuthPolicies(services); - services.Configure(configuration.GetSection("UltimateAuth:Server")); - - return services.AddUltimateAuthServerInternal(); - } - - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action configure) - { - services.AddUltimateAuth(); - AddUsersInternal(services); - AddCredentialsInternal(services); - AddUltimateAuthPolicies(services); - services.Configure(configure); - - return services.AddUltimateAuthServerInternal(); - } - - private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) - { - //services.AddSingleton(); - //services.PostConfigure(o => - //{ - // if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) - // return; - - // using var sp = services.BuildServiceProvider(); - // var detector = sp.GetRequiredService(); - // o.ClientProfile = detector.Detect(sp); - //}); - - //services.AddOptions() - // .PostConfigure>((server, core) => - // { - // ConfigureDefaults.ApplyClientProfileDefaults(server, core.Value); - // ConfigureDefaults.ApplyModeDefaults(server); - // ConfigureDefaults.ApplyAuthResponseDefaults(server, core.Value); - // }); - - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - - services.TryAddSingleton(sp => - { - var keyProvider = sp.GetRequiredService(); - var key = keyProvider.Resolve(null); - - return new HmacSha256TokenHasher( - ((SymmetricSecurityKey)key.Key).Key); - }); - - - // ----------------------------- - // OPTIONS VALIDATION - // ----------------------------- - services.TryAddEnumerable(ServiceDescriptor.Singleton, UAuthServerOptionsValidator>()); - - // ----------------------------- - // 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 FixedTenantResolver(opts.DefaultTenantId ?? "default"), - 1 => resolvers[0], - _ => new CompositeTenantResolver(resolvers) - }; - }); - - // Inner resolvers - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Public resolver - services.TryAddScoped(); - - services.TryAddScoped(); - - services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); - services.TryAddScoped(typeof(IRefreshFlowService), typeof(DefaultRefreshFlowService)); - services.TryAddScoped(typeof(IUAuthSessionManager), typeof(UAuthSessionManager)); - - services.TryAddSingleton(); - - // TODO: Allow custom cookie manager via options - //services.AddSingleton(); - //if (options.CustomCookieManagerType is not null) - //{ - // services.AddSingleton(typeof(IUAuthSessionCookieManager), options.CustomCookieManagerType); - //} - //else - //{ - // services.AddSingleton(); - //} - - // ----------------------------- - // SESSION / TOKEN ISSUERS - // ----------------------------- - services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); - services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); - - services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); - services.TryAddScoped(); - - //services.TryAddScoped(typeof(IUserAuthenticator<>), typeof(DefaultUserAuthenticator<>)); - services.TryAddScoped(typeof(ISessionOrchestrator), typeof(UAuthSessionOrchestrator)); - services.TryAddScoped(typeof(ILoginOrchestrator<>), typeof(DefaultLoginOrchestrator<>)); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(typeof(ISessionQueryService), typeof(UAuthSessionQueryService)); - services.TryAddScoped(typeof(IRefreshTokenResolver), typeof(DefaultRefreshTokenResolver)); - services.TryAddScoped(typeof(ISessionTouchService), typeof(DefaultSessionTouchService)); - services.TryAddScoped(); - services.TryAddScoped(); - 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.TryAddSingleton(); - services.TryAddScoped(); - - services.TryAddScoped(typeof(IRefreshTokenValidator), typeof(DefaultRefreshTokenValidator)); - services.TryAddScoped(); - services.TryAddScoped(typeof(IRefreshTokenRotationService), typeof(RefreshTokenRotationService)); - - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddScoped(); - - services.TryAddScoped(); - - services.TryAddSingleton(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - - services.TryAddScoped(); - - // ----------------------------- - // ENDPOINTS - // ----------------------------- - services.AddHttpContextAccessor(); - - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - - services.TryAddScoped(); - - - services.TryAddSingleton(); - - // Endpoint handlers - 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(); - - return services; - } - - //internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) - //{ - // if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) - // throw new InvalidOperationException("UltimateAuth policies already registered."); - - // var registry = new AccessPolicyRegistry(); - - // DefaultPolicySet.Register(registry); - // configure?.Invoke(registry); - // services.AddSingleton(registry); - // services.AddSingleton(sp => - // { - // var compiled = registry.Build(); - // return new DefaultAccessPolicyProvider(compiled, sp); - // }); - - // services.TryAddScoped(sp => - // { - // var invariants = sp.GetServices(); - // var globalPolicies = sp.GetServices(); - - // return new DefaultAccessAuthority(invariants, globalPolicies); - // }); - - // services.TryAddScoped(); - - - // return services; - //} - - internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) - { - if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) - throw new InvalidOperationException("UltimateAuth policies already registered."); - - var registry = new AccessPolicyRegistry(); - - DefaultPolicySet.Register(registry); - configure?.Invoke(registry); - - // 1. Registry (global, mutable until Build) - services.AddSingleton(registry); - - // 2. Compiled policy set (immutable, singleton) - services.AddSingleton(sp => - { - var r = sp.GetRequiredService(); - return r.Build(); - }); - - // 3. Policy provider MUST be scoped - services.AddScoped(); - - // 4. Authority (scoped, correct) - services.TryAddScoped(sp => - { - var invariants = sp.GetServices(); - var globalPolicies = sp.GetServices(); - return new DefaultAccessAuthority(invariants, globalPolicies); - }); - - // 5. Orchestrator (scoped) - services.TryAddScoped(); - - return services; - } - - // ========================= - // USERS (FRAMEWORK-REQUIRED) - // ========================= - internal static IServiceCollection AddUsersInternal(IServiceCollection services) - { - // Core user abstractions - services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); - services.TryAddScoped(); - - // Security state - //services.TryAddScoped(typeof(IUserSecurityEvents<>), typeof(DefaultUserSecurityEvents<>)); - - // TODO: Move this into AddAuthorizaionInternal method - services.TryAddScoped(typeof(IUserClaimsProvider), typeof(DefaultAuthorizationClaimsProvider)); - - return services; - } - - // ========================= - // CREDENTIALS (FRAMEWORK-REQUIRED) - // ========================= - internal static IServiceCollection AddCredentialsInternal(IServiceCollection services) - { - services.TryAddScoped(); - return services; - } - - - public static IServiceCollection AddUAuthServerInfrastructure(this IServiceCollection services) - { - // Flow orchestration - services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); - - // Issuers - services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); - services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); - - // Endpoints - services.TryAddSingleton(); - - // Cookie management (default) - services.TryAddSingleton(); - - return services; - } - - } -} 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..f8f8eac4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs @@ -0,0 +1,15 @@ +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); +} 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..defffe30 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -0,0 +1,33 @@ +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.CredentialsValid) + { + return LoginDecision.Deny("Invalid credentials."); + } + + if (!context.UserExists || context.UserKey is null) + { + return LoginDecision.Deny("Invalid credentials."); + } + + var state = context.SecurityState; + if (state is not null) + { + if (state.IsLocked) + return LoginDecision.Deny("user_is_locked"); + + if (state.RequiresReauthentication) + return LoginDecision.Challenge("reauth_required"); + } + + 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..68db4061 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecision.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Represents the outcome of a login decision. +/// +public sealed class LoginDecision +{ + public LoginDecisionKind Kind { get; } + public string? Reason { get; } + + private LoginDecision(LoginDecisionKind kind, string? reason = null) + { + Kind = kind; + Reason = reason; + } + + public static LoginDecision Allow() + => new(LoginDecisionKind.Allow); + + public static LoginDecision Deny(string reason) + => new(LoginDecisionKind.Deny, reason); + + public static LoginDecision Challenge(string 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..72a53af6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs @@ -0,0 +1,50 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users; + +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; } + + /// + /// 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/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs new file mode 100644 index 00000000..40e008dc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -0,0 +1,167 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +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.Users; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal sealed class LoginOrchestrator : ILoginOrchestrator +{ + private readonly ICredentialStore _credentialStore; // authentication + private readonly ICredentialValidator _credentialValidator; + private readonly IUserRuntimeStateProvider _users; // eligible + private readonly IUserSecurityStateProvider _userSecurityStateProvider; // runtime risk + private readonly ILoginAuthority _authority; + private readonly ISessionOrchestrator _sessionOrchestrator; + private readonly ITokenIssuer _tokens; + private readonly IUserClaimsProvider _claimsProvider; + private readonly IUserIdConverterResolver _userIdConverterResolver; + + public LoginOrchestrator( + ICredentialStore credentialStore, + ICredentialValidator credentialValidator, + IUserRuntimeStateProvider users, + IUserSecurityStateProvider userSecurityStateProvider, + ILoginAuthority authority, + ISessionOrchestrator sessionOrchestrator, + ITokenIssuer tokens, + IUserClaimsProvider claimsProvider, + IUserIdConverterResolver userIdConverterResolver) + { + _credentialStore = credentialStore; + _credentialValidator = credentialValidator; + _users = users; + _userSecurityStateProvider = userSecurityStateProvider; + _authority = authority; + _sessionOrchestrator = sessionOrchestrator; + _tokens = tokens; + _claimsProvider = claimsProvider; + _userIdConverterResolver = userIdConverterResolver; + } + + public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var now = request.At ?? DateTimeOffset.UtcNow; + + var credentials = await _credentialStore.FindByLoginAsync(request.Tenant, request.Identifier, ct); + var orderedCredentials = credentials + .OfType() + .Where(c => c.Security.IsUsable(now)) + .Cast>() + .ToList(); + + TUserId validatedUserId = default!; + bool credentialsValid = false; + + foreach (var credential in orderedCredentials) + { + var result = await _credentialValidator.ValidateAsync(credential, request.Secret, ct); + + if (result.IsValid) + { + validatedUserId = credential.UserId; + credentialsValid = true; + break; + } + } + + bool userExists = credentialsValid; + + IUserSecurityState? securityState = null; + UserKey? userKey = null; + + if (credentialsValid) + { + securityState = await _userSecurityStateProvider.GetAsync(request.Tenant, validatedUserId, ct); + var converter = _userIdConverterResolver.GetConverter(); + userKey = UserKey.FromString(converter.ToCanonicalString(validatedUserId)); + } + + var user = userKey is not null + ? await _users.GetAsync(request.Tenant, userKey.Value, ct) + : null; + + if (user is null || user.IsDeleted || !user.IsActive) + { + // Deliberately vague + return LoginResult.Failed(); + } + + var decisionContext = new LoginDecisionContext + { + Tenant = request.Tenant, + Identifier = request.Identifier, + CredentialsValid = credentialsValid, + UserExists = userExists, + UserKey = userKey, + SecurityState = securityState, + IsChained = request.ChainId is not null + }; + + var decision = _authority.Decide(decisionContext); + + if (decision.Kind == LoginDecisionKind.Deny) + return LoginResult.Failed(); + + if (decision.Kind == LoginDecisionKind.Challenge) + { + return LoginResult.Continue(new LoginContinuation + { + Type = LoginContinuationType.Mfa, + Hint = decision.Reason + }); + } + + if (userKey is not UserKey validUserKey) + { + return LoginResult.Failed(); + } + + var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, validUserKey, ct); + + var sessionContext = new AuthenticatedSessionContext + { + Tenant = request.Tenant, + UserKey = validUserKey, + Now = now, + Device = request.Device, + Claims = claims, + ChainId = request.ChainId, + Metadata = SessionMetadata.Empty + }; + + 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 = request.Tenant, + UserKey = validUserKey, + SessionId = issuedSession.Session.SessionId, + ChainId = request.ChainId, + Claims = claims.AsDictionary() + }; + + tokens = new AuthTokens + { + AccessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct), + RefreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct) + }; + } + + return LoginResult.Success(issuedSession.Session.SessionId, tokens); + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/IPkceAuthorizationValidator.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs rename to src/CodeBeam.UltimateAuth.Server/Flows/Pkce/IPkceAuthorizationValidator.cs index cef6bdaa..e0bafe9a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/IPkceAuthorizationValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/IPkceAuthorizationValidator.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +namespace CodeBeam.UltimateAuth.Server.Flows; public interface IPkceAuthorizationValidator { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs rename to src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs index 605a8179..3f1fdc72 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Stores; -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +namespace CodeBeam.UltimateAuth.Server.Flows; /// /// Represents a PKCE authorization process that has been initiated diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs similarity index 82% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs rename to src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs index d479e3a7..0265ce7f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizationValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs @@ -1,7 +1,8 @@ -using System.Security.Cryptography; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Security.Cryptography; using System.Text; -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +namespace CodeBeam.UltimateAuth.Server.Flows; internal sealed class PkceAuthorizationValidator : IPkceAuthorizationValidator { @@ -35,7 +36,7 @@ private static bool IsContextValid(PkceContextSnapshot original, PkceContextSnap if (!original.ClientProfile.Equals(completion.ClientProfile)) return false; - if (!string.Equals(original.TenantId, completion.TenantId, StringComparison.Ordinal)) + if (!string.Equals(original.Tenant, completion.Tenant, StringComparison.Ordinal)) return false; if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal)) @@ -55,16 +56,8 @@ private static bool IsVerifierValid(string verifier, string expectedChallenge) using var sha256 = SHA256.Create(); byte[] hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); - string computedChallenge = Base64UrlEncode(hash); + string computedChallenge = Base64Url.Encode(hash); return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(computedChallenge), Encoding.ASCII.GetBytes(expectedChallenge)); } - - private static string Base64UrlEncode(byte[] input) - { - return Convert.ToBase64String(input) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs similarity index 78% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs rename to src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs index d1115964..50622f30 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceAuthorizeRequest.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +namespace CodeBeam.UltimateAuth.Server.Flows; internal sealed class PkceAuthorizeRequest { 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/Infrastructure/Pkce/PkceContextSnapshot.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs similarity index 82% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs rename to src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs index c826b12a..37c10714 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceContextSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs @@ -1,6 +1,7 @@ -using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +namespace CodeBeam.UltimateAuth.Server.Flows; /// /// Immutable snapshot of relevant request and client context @@ -11,12 +12,12 @@ public sealed class PkceContextSnapshot { public PkceContextSnapshot( UAuthClientProfile clientProfile, - string? tenantId, + TenantKey tenant, string? redirectUri, string? deviceId) { ClientProfile = clientProfile; - TenantId = tenantId; + Tenant = tenant; RedirectUri = redirectUri; DeviceId = deviceId; } @@ -29,7 +30,7 @@ public PkceContextSnapshot( /// /// Tenant context at the time of authorization. /// - public string? TenantId { get; } + public TenantKey Tenant { get; } /// /// Redirect URI used during authorization. diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs similarity index 75% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs rename to src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs index 6f3dc072..29a6eef5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationFailureReason.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +namespace CodeBeam.UltimateAuth.Server.Flows; public enum PkceValidationFailureReason { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs similarity index 89% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs rename to src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs index ea7b6051..a6c0dae9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +namespace CodeBeam.UltimateAuth.Server.Flows; public sealed class PkceValidationResult { 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..fbb34c10 --- /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 +{ + CredentialKind 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..6e909cdb --- /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 CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) + { + if (flow.EffectiveMode == UAuthMode.PureOpaque) + return CredentialKind.Session; + + if (flow.EffectiveMode == UAuthMode.PureJwt) + return CredentialKind.AccessToken; + + if (!string.IsNullOrWhiteSpace(request.RefreshToken) && request.SessionId == null) + { + return CredentialKind.AccessToken; + } + + if (request.SessionId != null) + { + return CredentialKind.Session; + } + + if (flow.ClientProfile == UAuthClientProfile.Api) + return CredentialKind.AccessToken; + + return CredentialKind.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..caf1f95e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs @@ -0,0 +1,31 @@ +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.EnableRefreshHeaders) + return; + + context.Response.Headers["X-UAuth-Refresh"] = outcome switch + { + RefreshOutcome.NoOp => "no-op", + RefreshOutcome.Touched => "touched", + RefreshOutcome.ReauthRequired => "reauth-required", + _ => "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..5df517b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +public sealed class SessionTouchService : ISessionTouchService +{ + private readonly ISessionStoreKernelFactory _kernelFactory; + + public SessionTouchService(ISessionStoreKernelFactory 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.SessionId 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 session = await kernel.GetSessionAsync(validation.SessionId.Value); + if (session is null || session.IsRevoked) + return; + + if (sessionTouchMode == SessionTouchMode.IfNeeded && now - session.LastSeenAt < policy.TouchInterval.Value) + return; + + var touched = session.Touch(now); + await kernel.SaveSessionAsync(touched); + didTouch = true; + }, ct); + + return SessionRefreshResult.Success(validation.SessionId.Value, didTouch); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultAccessPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs similarity index 76% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultAccessPolicyProvider.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs index 008ca145..0a991193 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultAccessPolicyProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs @@ -5,12 +5,12 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed class DefaultAccessPolicyProvider : IAccessPolicyProvider +internal sealed class AccessPolicyProvider : IAccessPolicyProvider { private readonly CompiledAccessPolicySet _set; private readonly IServiceProvider _services; - public DefaultAccessPolicyProvider(CompiledAccessPolicySet set, IServiceProvider services) + public AccessPolicyProvider(CompiledAccessPolicySet set, IServiceProvider services) { _set = set; _services = services; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs index c0ed6ffe..54667497 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs @@ -2,64 +2,62 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class AuthRedirectResolver { - public sealed class AuthRedirectResolver + private readonly UAuthServerOptions _options; + + public AuthRedirectResolver(IOptions options) { - private readonly UAuthServerOptions _options; + _options = options.Value; + } - public AuthRedirectResolver(IOptions options) + // TODO: Add allowed origins validation + public string ResolveClientBase(HttpContext ctx) + { + if (ctx.Request.Query.TryGetValue("returnUrl", out var returnUrl) && + Uri.TryCreate(returnUrl!, UriKind.Absolute, out var ru)) { - _options = options.Value; + return ru.GetLeftPart(UriPartial.Authority); } - // TODO: Add allowed origins validation - public string ResolveClientBase(HttpContext ctx) + if (ctx.Request.Headers.TryGetValue("Origin", out var origin) && + Uri.TryCreate(origin!, UriKind.Absolute, out var originUri)) { - if (ctx.Request.Query.TryGetValue("returnUrl", out var returnUrl) && - Uri.TryCreate(returnUrl!, UriKind.Absolute, out var ru)) - { - return ru.GetLeftPart(UriPartial.Authority); - } - - if (ctx.Request.Headers.TryGetValue("Origin", out var origin) && - Uri.TryCreate(origin!, UriKind.Absolute, out var originUri)) - { - return originUri.GetLeftPart(UriPartial.Authority); - } - - if (ctx.Request.Headers.TryGetValue("Referer", out var referer) && - Uri.TryCreate(referer!, UriKind.Absolute, out var refUri)) - { - return refUri.GetLeftPart(UriPartial.Authority); - } - - if (!string.IsNullOrWhiteSpace(_options.Hub.ClientBaseAddress)) - return _options.Hub.ClientBaseAddress; - - return $"{ctx.Request.Scheme}://{ctx.Request.Host}"; + return originUri.GetLeftPart(UriPartial.Authority); } - public string ResolveRedirect(HttpContext ctx, string path, IDictionary? query = null) + if (ctx.Request.Headers.TryGetValue("Referer", out var referer) && + Uri.TryCreate(referer!, UriKind.Absolute, out var refUri)) { - var url = Combine(ResolveClientBase(ctx), path); + return refUri.GetLeftPart(UriPartial.Authority); + } - if (query is null || query.Count == 0) - return url; + if (!string.IsNullOrWhiteSpace(_options.Hub.ClientBaseAddress)) + return _options.Hub.ClientBaseAddress; - var qs = string.Join("&", query - .Where(kv => !string.IsNullOrWhiteSpace(kv.Value)) - .Select(kv => $"{kv.Key}={Uri.EscapeDataString(kv.Value!)}")); + return $"{ctx.Request.Scheme}://{ctx.Request.Host}"; + } - return string.IsNullOrWhiteSpace(qs) - ? url - : $"{url}?{qs}"; - } + public string ResolveRedirect(HttpContext ctx, string path, IDictionary? query = null) + { + var url = Combine(ResolveClientBase(ctx), path); - private static string Combine(string baseUri, string path) - { - return 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}"; } + private static string Combine(string baseUri, string path) + { + return baseUri.TrimEnd('/') + "/" + path.TrimStart('/'); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs deleted file mode 100644 index 657f99b6..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/DefaultTransportCredentialResolver.cs +++ /dev/null @@ -1,171 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -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 DefaultTransportCredentialResolver : ITransportCredentialResolver - { - private readonly IOptionsMonitor _server; - - public DefaultTransportCredentialResolver(IOptionsMonitor server) - { - _server = server; - } - - public TransportCredential? Resolve(HttpContext context) - { - var cookies = _server.CurrentValue.Cookie; - - // 1️⃣ Authorization header (Bearer) - if (TryFromAuthorizationHeader(context, out var bearer)) - return bearer; - - // 2️⃣ Cookies (session / refresh / access) - if (TryFromCookies(context, cookies, out var cookie)) - return cookie; - - // 3️⃣ Query (legacy / special flows) - if (TryFromQuery(context, out var query)) - return query; - - // 4️⃣ Body (rare, but possible – PKCE / device flows) - if (TryFromBody(context, out var body)) - return body; - - // 5️⃣ Hub / external authority - if (TryFromHub(context, out var hub)) - return hub; - - return null; - } - - // ---------- resolvers ---------- - - // TODO: Make scheme configurable, shouldn't be hard coded - private static bool TryFromAuthorizationHeader(HttpContext ctx, out TransportCredential credential) - { - credential = default!; - - if (!ctx.Request.Headers.TryGetValue("Authorization", out var header)) - return false; - - var value = header.ToString(); - if (!value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return false; - - var token = value["Bearer ".Length..].Trim(); - if (string.IsNullOrWhiteSpace(token)) - return false; - - credential = new TransportCredential - { - Kind = TransportCredentialKind.AccessToken, - Value = token, - TenantId = ctx.GetTenantContext().TenantId, - Device = ctx.GetDevice() - }; - - return true; - } - - private static bool TryFromCookies( - HttpContext ctx, - UAuthCookieSetOptions cookieSet, - out TransportCredential credential) - { - credential = default!; - - // Session cookie - if (TryReadCookie(ctx, cookieSet.Session.Name, out var session)) - { - credential = Build(ctx, TransportCredentialKind.Session, session); - return true; - } - - // Refresh token cookie - if (TryReadCookie(ctx, cookieSet.RefreshToken.Name, out var refresh)) - { - credential = Build(ctx, TransportCredentialKind.RefreshToken, refresh); - return true; - } - - // Access token cookie (optional) - if (TryReadCookie(ctx, cookieSet.AccessToken.Name, out var access)) - { - credential = Build(ctx, TransportCredentialKind.AccessToken, access); - return true; - } - - return false; - } - - private static bool TryFromQuery(HttpContext ctx, out TransportCredential credential) - { - credential = default!; - - if (!ctx.Request.Query.TryGetValue("access_token", out var token)) - return false; - - var value = token.ToString(); - if (string.IsNullOrWhiteSpace(value)) - return false; - - credential = new TransportCredential - { - Kind = TransportCredentialKind.AccessToken, - Value = value, - TenantId = ctx.GetTenantContext().TenantId, - Device = ctx.GetDevice() - }; - - return true; - } - - private static bool TryFromBody(HttpContext ctx, out TransportCredential credential) - { - credential = default!; - // intentionally empty for now - // body parsing is expensive and opt-in later - return false; - } - - private static bool TryFromHub(HttpContext ctx, out TransportCredential credential) - { - credential = default!; - // UAuthHub detection can live here later - return false; - } - - 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 TransportCredential Build(HttpContext ctx, TransportCredentialKind kind, string value) - => new() - { - Kind = kind, - Value = value, - TenantId = ctx.GetTenantContext().TenantId, - Device = ctx.GetDevice() - }; - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs index 593e592c..22015398 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface ITransportCredentialResolver { - public interface ITransportCredentialResolver - { - TransportCredential? Resolve(HttpContext context); - } + TransportCredential? Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs index 361b3acd..aac3255f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class TransportCredential { - public sealed class TransportCredential - { - public required TransportCredentialKind Kind { get; init; } - public required string Value { get; init; } + public required TransportCredentialKind Kind { get; init; } + public required string Value { get; init; } - public string? TenantId { get; init; } - public required DeviceInfo Device { 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 index 2638040c..a33ad8bd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public enum TransportCredentialKind { - public enum TransportCredentialKind - { - Session, - AccessToken, - RefreshToken, - Hub - } + 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..71cfcff3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs @@ -0,0 +1,160 @@ +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 TransportCredential? Resolve(HttpContext context) + { + var cookies = _server.CurrentValue.Cookie; + + if (TryFromAuthorizationHeader(context, out var bearer)) + return bearer; + + if (TryFromCookies(context, cookies, out var cookie)) + return cookie; + + if (TryFromQuery(context, out var query)) + return query; + + if (TryFromBody(context, out var body)) + return body; + + if (TryFromHub(context, out var hub)) + return hub; + + return null; + } + + // TODO: Make scheme configurable, shouldn't be hard coded + private static bool TryFromAuthorizationHeader(HttpContext ctx, out TransportCredential credential) + { + credential = default!; + + if (!ctx.Request.Headers.TryGetValue("Authorization", out var header)) + return false; + + var value = header.ToString(); + if (!value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return false; + + var token = value["Bearer ".Length..].Trim(); + if (string.IsNullOrWhiteSpace(token)) + return false; + + credential = new TransportCredential + { + Kind = TransportCredentialKind.AccessToken, + Value = token, + TenantId = ctx.GetTenant().Value, + Device = ctx.GetDevice() + }; + + return true; + } + + private static bool TryFromCookies( + HttpContext ctx, + UAuthCookieSetOptions cookieSet, + out TransportCredential credential) + { + credential = default!; + + // Session cookie + if (TryReadCookie(ctx, cookieSet.Session.Name, out var session)) + { + credential = Build(ctx, TransportCredentialKind.Session, session); + return true; + } + + // Refresh token cookie + if (TryReadCookie(ctx, cookieSet.RefreshToken.Name, out var refresh)) + { + credential = Build(ctx, TransportCredentialKind.RefreshToken, refresh); + return true; + } + + // Access token cookie (optional) + if (TryReadCookie(ctx, cookieSet.AccessToken.Name, out var access)) + { + credential = Build(ctx, TransportCredentialKind.AccessToken, access); + return true; + } + + return false; + } + + private static bool TryFromQuery(HttpContext ctx, out TransportCredential credential) + { + credential = default!; + + if (!ctx.Request.Query.TryGetValue("access_token", out var token)) + return false; + + var value = token.ToString(); + if (string.IsNullOrWhiteSpace(value)) + return false; + + credential = new TransportCredential + { + Kind = TransportCredentialKind.AccessToken, + Value = value, + TenantId = ctx.GetTenant().Value, + Device = ctx.GetDevice() + }; + + return true; + } + + private static bool TryFromBody(HttpContext ctx, out TransportCredential credential) + { + credential = default!; + // intentionally empty for now + // body parsing is expensive and opt-in later + return false; + } + + private static bool TryFromHub(HttpContext ctx, out TransportCredential credential) + { + credential = default!; + // UAuthHub detection can live here later + return false; + } + + 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 TransportCredential Build(HttpContext ctx, TransportCredentialKind kind, string value) + => new() + { + Kind = kind, + Value = value, + TenantId = ctx.GetTenant().Value, + Device = ctx.GetDevice() + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultCredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultCredentialResponseWriter.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index 31f07bac..0334dd82 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultCredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -4,20 +4,19 @@ using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Cookies; -using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed class DefaultCredentialResponseWriter : ICredentialResponseWriter +internal sealed class CredentialResponseWriter : ICredentialResponseWriter { private readonly IAuthFlowContextAccessor _authContext; private readonly IUAuthCookieManager _cookieManager; private readonly IUAuthCookiePolicyBuilder _cookiePolicy; private readonly IUAuthHeaderPolicyBuilder _headerPolicy; - public DefaultCredentialResponseWriter( + public CredentialResponseWriter( IAuthFlowContextAccessor authContext, IUAuthCookieManager cookieManager, IUAuthCookiePolicyBuilder cookiePolicy, diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultFlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultFlowCredentialResolver.cs deleted file mode 100644 index 921447aa..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultFlowCredentialResolver.cs +++ /dev/null @@ -1,93 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Abstractions; -using CodeBeam.UltimateAuth.Server.Auth; -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 DefaultFlowCredentialResolver : IFlowCredentialResolver - { - private readonly IPrimaryCredentialResolver _primaryResolver; - - public DefaultFlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) - { - _primaryResolver = primaryResolver; - } - - public ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response) - { - var kind = _primaryResolver.Resolve(context); - - return kind switch - { - PrimaryCredentialKind.Stateful => ResolveSession(context, response), - PrimaryCredentialKind.Stateless => ResolveAccessToken(context, response), - - _ => null - }; - } - - private static ResolvedCredential? 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 = PrimaryCredentialKind.Stateful, - Value = raw.Trim(), - TenantId = context.GetTenantContext().TenantId, - Device = context.GetDevice() - }; - } - - private static ResolvedCredential? 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 = PrimaryCredentialKind.Stateless, - Value = value, - TenantId = context.GetTenantContext().TenantId, - Device = context.GetDevice() - }; - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultPrimaryCredentialResolver.cs deleted file mode 100644 index 90a2b016..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultPrimaryCredentialResolver.cs +++ /dev/null @@ -1,39 +0,0 @@ -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 DefaultPrimaryCredentialResolver : IPrimaryCredentialResolver - { - private readonly UAuthServerOptions _options; - - public DefaultPrimaryCredentialResolver(IOptions options) - { - _options = options.Value; - } - - public PrimaryCredentialKind 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/DefaultUAuthBodyPolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthBodyPolicyBuilder.cs deleted file mode 100644 index 4b35299c..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthBodyPolicyBuilder.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal class DefaultUAuthBodyPolicyBuilder : IUAuthBodyPolicyBuilder - { - public object BuildBodyValue(string rawValue, CredentialResponseOptions response, AuthFlowContext context) - { - throw new NotImplementedException(); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthHeaderPolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthHeaderPolicyBuilder.cs deleted file mode 100644 index 0e628c34..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultUAuthHeaderPolicyBuilder.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed class DefaultUAuthHeaderPolicyBuilder : 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/FlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs new file mode 100644 index 00000000..ffe1092d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs @@ -0,0 +1,91 @@ +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 FlowCredentialResolver : IFlowCredentialResolver +{ + private readonly IPrimaryCredentialResolver _primaryResolver; + + public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) + { + _primaryResolver = primaryResolver; + } + + public ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response) + { + var kind = _primaryResolver.Resolve(context); + + return kind switch + { + PrimaryCredentialKind.Stateful => ResolveSession(context, response), + PrimaryCredentialKind.Stateless => ResolveAccessToken(context, response), + + _ => null + }; + } + + private static ResolvedCredential? 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 = PrimaryCredentialKind.Stateful, + Value = raw.Trim(), + Tenant = context.GetTenant(), + Device = context.GetDevice() + }; + } + + private static ResolvedCredential? 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 = PrimaryCredentialKind.Stateless, + Value = value, + Tenant = context.GetTenant(), + Device = context.GetDevice() + }; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs index 9c849c63..a798db95 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs @@ -1,15 +1,14 @@ -using CodeBeam.UltimateAuth.Server.Abstractions; -using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Contracts; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +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 IFlowCredentialResolver { - /// - /// Gets the credential from the HTTP context. - /// IPrimaryCredentialResolver is used to determine which kind of credential to resolve. - /// - public interface IFlowCredentialResolver - { - ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response); - } + ResolvedCredential? Resolve(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..595f7aae --- /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 PrimaryCredentialKind 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/DefaultJwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultJwtTokenGenerator.cs deleted file mode 100644 index 6d9f9006..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultJwtTokenGenerator.cs +++ /dev/null @@ -1,71 +0,0 @@ -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 DefaultJwtTokenGenerator : IJwtTokenGenerator - { - private readonly IJwtSigningKeyProvider _keyProvider; - private readonly JsonWebTokenHandler _handler = new(); - - public DefaultJwtTokenGenerator(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 - }; - - if (descriptor.TenantId is not null) - { - claims["tenant"] = descriptor.TenantId; - } - - 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/DefaultOpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultOpaqueTokenGenerator.cs deleted file mode 100644 index 8a31f216..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultOpaqueTokenGenerator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed class DefaultOpaqueTokenGenerator : IOpaqueTokenGenerator - { - public string Generate(int bytes) - => Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(bytes)); - } - -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs index d3eac437..975f6c1f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs @@ -1,32 +1,32 @@ using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Contracts; using Microsoft.IdentityModel.Tokens; using System.Text; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class DevelopmentJwtSigningKeyProvider : IJwtSigningKeyProvider { - public sealed class DevelopmentJwtSigningKeyProvider : IJwtSigningKeyProvider + private readonly JwtSigningKey _key; + + public DevelopmentJwtSigningKeyProvider() { - private readonly JwtSigningKey _key; + var rawKey = Encoding.UTF8.GetBytes("DEV_ONLY__ULTIMATEAUTH__DO_NOT_USE_IN_PROD"); - public DevelopmentJwtSigningKeyProvider() + _key = new JwtSigningKey { - 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", - Algorithm = SecurityAlgorithms.HmacSha256, - Key = new SymmetricSecurityKey(rawKey) - { - KeyId = "dev-uauth" - } - }; - } + KeyId = "dev-uauth" + } + }; + } - public JwtSigningKey Resolve(string? keyId) - { - // signing veya verify için tek key - return _key; - } + public JwtSigningKey Resolve(string? keyId) + { + // signing veya verify için tek key + return _key; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs deleted file mode 100644 index 5a69b57e..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceContextFactory.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using System.Security; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed class DefaultDeviceContextFactory : IDeviceContextFactory - { - public DeviceContext Create(DeviceInfo device) - { - if (string.IsNullOrWhiteSpace(device.DeviceId.Value)) - return DeviceContext.Anonymous(); - - return DeviceContext.FromDeviceId(device.DeviceId); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs deleted file mode 100644 index a8c7cc9a..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DefaultDeviceResolver.cs +++ /dev/null @@ -1,58 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Abstractions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class DefaultDeviceResolver : IDeviceResolver - { - public DeviceInfo Resolve(HttpContext context) - { - var request = context.Request; - - var rawDeviceId = ResolveRawDeviceId(context); - DeviceId.TryCreate(rawDeviceId, out var deviceId); - - return new DeviceInfo - { - DeviceId = deviceId, - Platform = ResolvePlatform(request), - UserAgent = request.Headers.UserAgent.ToString(), - IpAddress = context.Connection.RemoteIpAddress?.ToString() - }; - } - - - private static string? ResolveRawDeviceId(HttpContext context) - { - if (context.Request.Headers.TryGetValue("X-UDID", out var header)) - return header.ToString(); - - if (context.Request.HasFormContentType && context.Request.Form.TryGetValue("__uauth_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? ResolvePlatform(HttpRequest request) - { - var ua = request.Headers.UserAgent.ToString().ToLowerInvariant(); - - 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 os")) return "macos"; - if (ua.Contains("linux")) return "linux"; - - return "web"; - } - - } -} 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..0a3a41b6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs @@ -0,0 +1,15 @@ +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 (string.IsNullOrWhiteSpace(device.DeviceId.Value)) + return DeviceContext.Anonymous(); + + return DeviceContext.FromDeviceId(device.DeviceId); + } +} 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..682fd36f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -0,0 +1,57 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class DeviceResolver : IDeviceResolver +{ + public DeviceInfo Resolve(HttpContext context) + { + var request = context.Request; + + var rawDeviceId = ResolveRawDeviceId(context); + DeviceId.TryCreate(rawDeviceId, out var deviceId); + + return new DeviceInfo + { + DeviceId = deviceId, + Platform = ResolvePlatform(request), + UserAgent = request.Headers.UserAgent.ToString(), + IpAddress = context.Connection.RemoteIpAddress?.ToString() + }; + } + + + private static string? ResolveRawDeviceId(HttpContext context) + { + if (context.Request.Headers.TryGetValue("X-UDID", out var header)) + return header.ToString(); + + if (context.Request.HasFormContentType && context.Request.Form.TryGetValue("__uauth_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? ResolvePlatform(HttpRequest request) + { + var ua = request.Headers.UserAgent.ToString().ToLowerInvariant(); + + 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 os")) return "macos"; + if (ua.Contains("linux")) return "linux"; + + return "web"; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs index 6ca4c192..b26db0de 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IDeviceContextFactory { - public interface IDeviceContextFactory - { - DeviceContext Create(DeviceInfo requestDevice); - } + DeviceContext Create(DeviceInfo requestDevice); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs index ab493228..31b36020 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs @@ -2,36 +2,34 @@ using System.Security.Cryptography; using System.Text; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class HmacSha256TokenHasher : ITokenHasher { - public sealed class HmacSha256TokenHasher : ITokenHasher - { - private readonly byte[] _key; + 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)); + public HmacSha256TokenHasher(byte[] key) + { + if (key is null || key.Length == 0) + throw new ArgumentException("Token hashing key must be provided.", nameof(key)); - _key = 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 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 plaintext, string hash) - { - var computed = Hash(plaintext); + public bool Verify(string plaintext, string hash) + { + var computed = Hash(plaintext); - return CryptographicOperations.FixedTimeEquals( - Convert.FromBase64String(computed), - Convert.FromBase64String(hash)); - } + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(computed), + Convert.FromBase64String(hash)); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs deleted file mode 100644 index bfc20d41..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubCredentialResolver.cs +++ /dev/null @@ -1,40 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Stores; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure.Hub -{ - internal sealed class DefaultHubCredentialResolver : IHubCredentialResolver - { - private readonly IAuthStore _store; - - public DefaultHubCredentialResolver(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)) - return null; - - if (!flow.Payload.TryGet("code_verifier", out string? codeVerifier)) - return null; - - return new HubCredentials - { - AuthorizationCode = authorizationCode, - CodeVerifier = codeVerifier, - ClientProfile = flow.ClientProfile, - }; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs deleted file mode 100644 index a49409ad..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/DefaultHubFlowReader.cs +++ /dev/null @@ -1,41 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Stores; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed class DefaultHubFlowReader : IHubFlowReader - { - private readonly IAuthStore _store; - private readonly IClock _clock; - - public DefaultHubFlowReader(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, - IsExpired = flow.IsExpired(now), - IsCompleted = flow.IsCompleted, - IsActive = !flow.IsExpired(now) && !flow.IsCompleted - }; - } - } - -} 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..646e780a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.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 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, + IsExpired = flow.IsExpired(now), + IsCompleted = flow.IsCompleted, + IsActive = !flow.IsExpired(now) && !flow.IsCompleted + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs index bfbc2ba8..e46d2bda 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class HubCapabilities : IHubCapabilities { - internal sealed class HubCapabilities : IHubCapabilities - { - public bool SupportsPkce => true; - } + 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..709aea0c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -0,0 +1,203 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +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 ISessionStoreKernelFactory _kernelFactory; + private readonly IOpaqueTokenGenerator _opaqueGenerator; + private readonly UAuthServerOptions _options; + + public UAuthSessionIssuer( + ISessionStoreKernelFactory kernelFactory, + IOpaqueTokenGenerator opaqueGenerator, + IOptions options) + { + _kernelFactory = kernelFactory; + _opaqueGenerator = opaqueGenerator; + _options = options.Value; + } + + public async Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) + { + // Defensive guard — enforcement belongs to Authority + if (_options.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 session = UAuthSession.Create( + sessionId: sessionId, + tenant: context.Tenant, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, + now: now, + expiresAt: expiresAt, + claims: context.Claims, + device: context.Device, + metadata: context.Metadata + ); + + var issued = new IssuedSession + { + Session = session, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }; + + var kernel = _kernelFactory.Create(context.Tenant); + + await kernel.ExecuteAsync(async _ => + { + var root = await kernel.GetSessionRootByUserAsync(context.UserKey) + ?? UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); + + UAuthSessionChain chain; + + if (context.ChainId is not null) + { + chain = await kernel.GetChainAsync(context.ChainId.Value) + ?? throw new SecurityException("Chain not found."); + } + else + { + chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + context.Tenant, + context.UserKey, + root.SecurityVersion, + ClaimsSnapshot.Empty); + + await kernel.SaveChainAsync(chain); + root = root.AttachChain(chain, now); + } + + var boundSession = session.WithChain(chain.ChainId); + + await kernel.SaveSessionAsync(boundSession); + await kernel.SetActiveSessionIdAsync(chain.ChainId, boundSession.SessionId); + await kernel.SaveSessionRootAsync(root); + }, ct); + + return issued; + } + + public async Task RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) + { + var kernel = _kernelFactory.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; + } + + var issued = new IssuedSession + { + Session = UAuthSession.Create( + sessionId: newSessionId, + tenant: context.Tenant, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, + now: now, + expiresAt: expiresAt, + device: context.Device, + claims: context.Claims, + metadata: context.Metadata + ), + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + }; + + await kernel.ExecuteAsync(async _ => + { + 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"); + + var chain = await kernel.GetChainAsync(oldSession.ChainId) + ?? throw new SecurityException("Chain not found"); + + var bound = issued.Session.WithChain(chain.ChainId); + + await kernel.SaveSessionAsync(bound); + await kernel.SetActiveSessionIdAsync(chain.ChainId, bound.SessionId); + await kernel.RevokeSessionAsync(oldSession.SessionId, now); + }, ct); + + return issued; + } + + public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _kernelFactory.Create(tenant); + await kernel.ExecuteAsync(_ => kernel.RevokeSessionAsync(sessionId, at), ct); + } + + public async Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _kernelFactory.Create(tenant); + await kernel.ExecuteAsync(_ => kernel.RevokeChainAsync(chainId, at), ct); + } + + public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _kernelFactory.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; + + if (!chain.IsRevoked) + await kernel.RevokeChainAsync(chain.ChainId, at); + + var activeSessionId = await kernel.GetActiveSessionIdAsync(chain.ChainId); + if (activeSessionId is not null) + await kernel.RevokeSessionAsync(activeSessionId.Value, at); + } + }, ct); + } + + // TODO: Discuss revoking chains/sessions when root is revoked + public async Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _kernelFactory.Create(tenant); + await kernel.ExecuteAsync(_ => kernel.RevokeSessionRootAsync(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..a30b4062 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -0,0 +1,145 @@ +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 IRefreshTokenStore _refreshTokenStore; + private readonly IUserIdConverterResolver _converterResolver; + private readonly IClock _clock; + + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore,IUserIdConverterResolver converterResolver, IClock clock) + { + _opaqueGenerator = opaqueGenerator; + _jwtGenerator = jwtGenerator; + _tokenHasher = tokenHasher; + _refreshTokenStore = refreshTokenStore; + _converterResolver = converterResolver; + _clock = clock; + } + + public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) + { + var tokens = flow.OriginalOptions.Tokens; + 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; + + var expires = _clock.UtcNow.Add(flow.OriginalOptions.Tokens.RefreshTokenLifetime); + + var raw = _opaqueGenerator.Generate(); + var hash = _tokenHasher.Hash(raw); + + if (context.SessionId is not AuthSessionId sessionId) + return null; + + var stored = new StoredRefreshToken + { + Tenant = flow.Tenant, + TokenHash = hash, + UserKey = context.UserKey, + SessionId = sessionId, + ChainId = context.ChainId, + IssuedAt = _clock.UtcNow, + ExpiresAt = expires + }; + + if (persistence == RefreshTokenPersistence.Persist) + { + await _refreshTokenStore.StoreAsync(flow.Tenant, stored, ct); + } + + return new RefreshToken + { + 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, + ["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.Generate(16); + + 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/JwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/JwtTokenGenerator.cs new file mode 100644 index 00000000..a9ab6d46 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/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/OpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs new file mode 100644 index 00000000..5de0cf3d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class OpaqueTokenGenerator : IOpaqueTokenGenerator +{ + public string Generate(int bytes) => Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(bytes)); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs index 6868542d..cd44daf7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand { - internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand + public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { - public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) - { - return issuer.IssueLoginSessionAsync(LoginContext, ct); - } + return issuer.IssueLoginSessionAsync(LoginContext, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs deleted file mode 100644 index 3f4f539e..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs +++ /dev/null @@ -1,60 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class DefaultAccessAuthority : IAccessAuthority - { - private readonly IEnumerable _invariants; - private readonly IEnumerable _globalPolicies; - - public DefaultAccessAuthority(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/IAccessCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs index a0dfdd31..9a2c61c5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs @@ -1,14 +1,12 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public interface IAccessCommand - { - Task ExecuteAsync(CancellationToken ct = default); - } +namespace CodeBeam.UltimateAuth.Server.Infrastructure; - // For get commands - public interface IAccessCommand - { - Task ExecuteAsync(CancellationToken ct = default); - } +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 index 5f8e8d9e..9ad05368 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IAccessOrchestrator { - public interface IAccessOrchestrator - { - Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default); - Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default); - } + 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 index 0040e5a8..e60da79a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface ISessionCommand { - public interface ISessionCommand - { - Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct); - } + 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 index 668a9d28..c1e75e5e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs @@ -1,8 +1,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Infrastructure + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal interface ISessionOrchestrator { - internal interface ISessionOrchestrator - { - Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default); - } + 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 index 47102fb5..51e814cd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs @@ -2,23 +2,22 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class RevokeAllChainsCommand : ISessionCommand { - public sealed class RevokeAllChainsCommand : ISessionCommand - { - public UserKey UserKey { get; } - public SessionChainId? ExceptChainId { get; } + public UserKey UserKey { get; } + public SessionChainId? ExceptChainId { get; } - public RevokeAllChainsCommand(UserKey userKey, SessionChainId? exceptChainId) - { - UserKey = userKey; - ExceptChainId = exceptChainId; - } + public RevokeAllChainsCommand(UserKey userKey, SessionChainId? exceptChainId) + { + UserKey = userKey; + ExceptChainId = exceptChainId; + } - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) - { - await issuer.RevokeAllChainsAsync(context.TenantId, UserKey, ExceptChainId, context.At, ct); - return Unit.Value; - } + 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/RevokeAllSessionsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs index cdffd578..4fc9fcb5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs @@ -2,23 +2,22 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class RevokeAllUserSessionsCommand : ISessionCommand - { - public UserKey UserKey { get; } +namespace CodeBeam.UltimateAuth.Server.Infrastructure; - public RevokeAllUserSessionsCommand(UserKey userKey) - { - UserKey = userKey; - } +public sealed class RevokeAllUserSessionsCommand : ISessionCommand +{ + public UserKey UserKey { get; } - // TODO: This method should call its own logic. Not revoke root. - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) - { - await issuer.RevokeRootAsync(context.TenantId, UserKey, context.At, ct); - return Unit.Value; - } + public RevokeAllUserSessionsCommand(UserKey userKey) + { + UserKey = userKey; + } + // TODO: This method should call its own logic. Not revoke root. + 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/RevokeChainCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs index c8c1a374..2432db8a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs @@ -2,29 +2,20 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator -{ - public sealed class RevokeChainCommand : ISessionCommand - { - public SessionChainId ChainId { get; } +namespace CodeBeam.UltimateAuth.Server.Infrastructure; - public RevokeChainCommand(SessionChainId chainId) - { - ChainId = chainId; - } +public sealed class RevokeChainCommand : ISessionCommand +{ + public SessionChainId ChainId { get; } - public async Task ExecuteAsync( - AuthContext context, - ISessionIssuer issuer, - CancellationToken ct) - { - await issuer.RevokeChainAsync( - context.TenantId, - ChainId, - context.At, - ct); + public RevokeChainCommand(SessionChainId chainId) + { + ChainId = chainId; + } - return Unit.Value; - } + 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 index a4f272af..ab3b2ce1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs @@ -2,21 +2,20 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class RevokeRootCommand : ISessionCommand { - public sealed class RevokeRootCommand : ISessionCommand - { - public UserKey UserKey { get; } + public UserKey UserKey { get; } - public RevokeRootCommand(UserKey userKey) - { - UserKey = userKey; - } + public RevokeRootCommand(UserKey userKey) + { + UserKey = userKey; + } - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) - { - await issuer.RevokeRootAsync(context.TenantId, UserKey, context.At, ct); - return Unit.Value; - } + 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 index 86fa7fd4..0bd2ee32 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs @@ -2,14 +2,13 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed record RevokeSessionCommand(AuthSessionId SessionId) : ISessionCommand { - internal sealed record RevokeSessionCommand(string? TenantId, AuthSessionId SessionId) : ISessionCommand + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) { - public async Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) - { - await issuer.RevokeSessionAsync(TenantId, SessionId, _.At, ct); - return Unit.Value; - } + await issuer.RevokeSessionAsync(context.Tenant, SessionId, context.At, ct); + return Unit.Value; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs index 1e52d029..70fc768f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand { - internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand + public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { - public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) - { - return issuer.RotateSessionAsync(RotationContext, 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 index 305c09b8..094fa5f2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -3,49 +3,48 @@ using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Policies.Abstractions; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UAuthAccessOrchestrator : IAccessOrchestrator { - public sealed class UAuthAccessOrchestrator : IAccessOrchestrator - { - private readonly IAccessAuthority _authority; - private readonly IAccessPolicyProvider _policyProvider; + private readonly IAccessAuthority _authority; + private readonly IAccessPolicyProvider _policyProvider; - public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider) - { - _authority = authority; - _policyProvider = policyProvider; - } + public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider) + { + _authority = authority; + _policyProvider = policyProvider; + } - public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - var policies = _policyProvider.GetPolicies(context); - var decision = _authority.Decide(context, policies); + var policies = _policyProvider.GetPolicies(context); + var decision = _authority.Decide(context, policies); - if (!decision.IsAllowed) - throw new UAuthAuthorizationException(decision.DenyReason); + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason); - if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); + if (decision.RequiresReauthentication) + throw new InvalidOperationException("Requires reauthentication."); - await command.ExecuteAsync(ct); - } + await command.ExecuteAsync(ct); + } - public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - var policies = _policyProvider.GetPolicies(context); - var decision = _authority.Decide(context, policies); + var policies = _policyProvider.GetPolicies(context); + var decision = _authority.Decide(context, policies); - if (!decision.IsAllowed) - throw new UAuthAuthorizationException(decision.DenyReason); + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason); - if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); + if (decision.RequiresReauthentication) + throw new InvalidOperationException("Requires reauthentication."); - return await command.ExecuteAsync(ct); - } + return await command.ExecuteAsync(ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs index 2a9c93c9..bc55c658 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -2,43 +2,42 @@ 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; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; - public UAuthSessionOrchestrator(IAuthAuthority authority, ISessionIssuer issuer) - { - _authority = authority; - _issuer = issuer; - } +public sealed class UAuthSessionOrchestrator : ISessionOrchestrator +{ + private readonly IAuthAuthority _authority; + private readonly ISessionIssuer _issuer; + private bool _executed; - 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."); + public UAuthSessionOrchestrator(IAuthAuthority authority, ISessionIssuer issuer) + { + _authority = authority; + _issuer = issuer; + } - _executed = true; + 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."); - var decision = _authority.Decide(authContext); + _executed = true; - switch (decision.Decision) - { - case AuthorizationDecision.Deny: - throw new UAuthAuthorizationException(decision.Reason); + var decision = _authority.Decide(authContext); - case AuthorizationDecision.Challenge: - throw new UAuthChallengeRequiredException(decision.Reason); + switch (decision.Decision) + { + case AuthorizationDecision.Deny: + throw new UAuthAuthorizationException(decision.Reason); - case AuthorizationDecision.Allow: - break; - } + case AuthorizationDecision.Challenge: + throw new UAuthChallengeRequiredException(decision.Reason); - return await command.ExecuteAsync(authContext, _issuer, ct); + case AuthorizationDecision.Allow: + break; } + return await command.ExecuteAsync(authContext, _issuer, ct); } + } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs deleted file mode 100644 index 39de36f0..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Pkce/PkceChallengeMethod.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public enum PkceChallengeMethod -{ - S256 -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs deleted file mode 100644 index ef3ce7ae..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponsePolicy.cs +++ /dev/null @@ -1,45 +0,0 @@ -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.Infrastructure -{ - internal class DefaultRefreshResponsePolicy : IRefreshResponsePolicy - { - public CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) - { - if (flow.EffectiveMode == UAuthMode.PureOpaque) - return CredentialKind.Session; - - if (flow.EffectiveMode == UAuthMode.PureJwt) - return CredentialKind.AccessToken; - - if (!string.IsNullOrWhiteSpace(request.RefreshToken) && request.SessionId == null) - { - return CredentialKind.AccessToken; - } - - if (request.SessionId != null) - { - return CredentialKind.Session; - } - - if (flow.ClientProfile == UAuthClientProfile.Api) - return CredentialKind.AccessToken; - - return CredentialKind.Session; - } - - - public bool WriteRefreshToken(AuthFlowContext flow) - { - if (flow.EffectiveMode != UAuthMode.PureOpaque) - return true; - - return false; - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponseWriter.cs deleted file mode 100644 index 837a3430..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshResponseWriter.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed class DefaultRefreshResponseWriter : IRefreshResponseWriter - { - private readonly UAuthDiagnosticsOptions _diagnostics; - - public DefaultRefreshResponseWriter(IOptions options) - { - _diagnostics = options.Value.Diagnostics; - } - - public void Write(HttpContext context, RefreshOutcome outcome) - { - if (!_diagnostics.EnableRefreshHeaders) - return; - - context.Response.Headers["X-UAuth-Refresh"] = outcome switch - { - RefreshOutcome.NoOp => "no-op", - RefreshOutcome.Touched => "touched", - RefreshOutcome.ReauthRequired => "reauth-required", - _ => "unknown" - }; - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs deleted file mode 100644 index f6fc373d..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultRefreshTokenResolver.cs +++ /dev/null @@ -1,41 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Abstractions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed class DefaultRefreshTokenResolver : 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/Infrastructure/Refresh/DefaultSessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs deleted file mode 100644 index c8a6c817..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/DefaultSessionTouchService.cs +++ /dev/null @@ -1,42 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class DefaultSessionTouchService : ISessionTouchService - { - private readonly ISessionStore _sessionStore; - - public DefaultSessionTouchService(ISessionStore sessionStore) - { - _sessionStore = sessionStore; - } - - // 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) - return SessionRefreshResult.ReauthRequired(); - - //var session = validation.Session; - bool didTouch = false; - - if (policy.TouchInterval.HasValue) - { - //var elapsed = now - session.LastSeenAt; - - //if (elapsed >= policy.TouchInterval.Value) - //{ - // var touched = session.Touch(now); - // await _activityWriter.TouchAsync(validation.TenantId, touched, ct); - // didTouch = true; - //} - - didTouch = await _sessionStore.TouchSessionAsync(validation.SessionId.Value, now, sessionTouchMode, ct); - } - - return SessionRefreshResult.Success(validation.SessionId.Value, didTouch); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs deleted file mode 100644 index 8c05919f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponsePolicy.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public interface IRefreshResponsePolicy - { - CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); - bool WriteRefreshToken(AuthFlowContext flow); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponseWriter.cs deleted file mode 100644 index 551fc0c1..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshResponseWriter.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public interface IRefreshResponseWriter - { - void Write(HttpContext context, RefreshOutcome outcome); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshService.cs deleted file mode 100644 index b4f85e26..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/IRefreshService.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - /// - /// Base contract for refresh-related services. - /// Refresh services renew authentication artifacts according to AuthMode. - /// - public interface IRefreshService - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs deleted file mode 100644 index 375fc310..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/ISessionTouchService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - /// - /// 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/Infrastructure/Refresh/RefreshDecision.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs deleted file mode 100644 index 1a9ccec6..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecision.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - /// - /// 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/Infrastructure/Refresh/RefreshDecisionResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs deleted file mode 100644 index b5e0b373..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshDecisionResolver.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - /// - /// 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/Infrastructure/Refresh/RefreshEvaluationResult.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshEvaluationResult.cs deleted file mode 100644 index f22aa1b8..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshEvaluationResult.cs +++ /dev/null @@ -1,6 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed record RefreshEvaluationResult(RefreshOutcome Outcome); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs deleted file mode 100644 index 33581937..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/RefreshStrategyResolver.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Contracts; -using System.Security; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - 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/Infrastructure/Refresh/SessionTouchPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs deleted file mode 100644 index 9f4fa8b5..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Refresh/SessionTouchPolicy.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class SessionTouchPolicy - { - public TimeSpan? TouchInterval { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/DefaultSessionContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/DefaultSessionContextAccessor.cs deleted file mode 100644 index b5b02b64..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/DefaultSessionContextAccessor.cs +++ /dev/null @@ -1,30 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure.Session -{ - public sealed class DefaultSessionContextAccessor : ISessionContextAccessor - { - private readonly IHttpContextAccessor _httpContextAccessor; - - public DefaultSessionContextAccessor(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - public SessionContext? Current - { - get - { - var ctx = _httpContextAccessor.HttpContext; - if (ctx is null) - return null; - - if (ctx.Items.TryGetValue(SessionContextItemKeys.SessionContext, out var value)) - return value as SessionContext; - - return null; - } - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs index ac69fc9f..252a38ef 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs @@ -1,12 +1,11 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +/// +/// The single point of truth for accessing the current session context +/// +public interface ISessionContextAccessor { - /// - /// The single point of truth for accessing the current session context - /// - public interface ISessionContextAccessor - { - SessionContext? Current { get; } - } + 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..434bcb52 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +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(SessionContextItemKeys.SessionContext, out var value)) + return value as SessionContext; + + return null; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs index b028fdc1..38e1379e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal static class SessionContextItemKeys { - internal static class SessionContextItemKeys - { - public const string SessionContext = "__UAuth.SessionContext"; - } + public const string SessionContext = "__UAuth.SessionContext"; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs index 5f852417..eb649433 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs @@ -1,36 +1,34 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal static class SessionValidationMapper { - internal static class SessionValidationMapper + public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result) { - public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result) + if (!result.IsValid) { - 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 - }; - } + if (result?.SessionId is null) + return null; return new SessionSecurityContext { - SessionId = result.SessionId!.Value, - State = SessionState.Active, + 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.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs index 6165f93f..c57b633f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs @@ -1,30 +1,28 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class BearerSessionIdResolver : IInnerSessionIdResolver { - public sealed class BearerSessionIdResolver : IInnerSessionIdResolver - { - public string Key => "bearer"; + public string Key => "bearer"; - public AuthSessionId? Resolve(HttpContext context) - { - var header = context.Request.Headers.Authorization.ToString(); - if (string.IsNullOrWhiteSpace(header)) - return null; + 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; + if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; - var raw = header["Bearer ".Length..].Trim(); - if (string.IsNullOrWhiteSpace(raw)) - return null; + var raw = header["Bearer ".Length..].Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return null; - if (!AuthSessionId.TryCreate(raw, out var sessionId)) - return null; + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; - return sessionId; - } + return sessionId; } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs index 978a55f1..fcec4400 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs @@ -1,28 +1,27 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +// TODO: Add policy and effective auth resolver. +public sealed class CompositeSessionIdResolver : ISessionIdResolver { - // TODO: Add policy and effective auth resolver. - public sealed class CompositeSessionIdResolver : ISessionIdResolver + private readonly IReadOnlyList _resolvers; + + public CompositeSessionIdResolver(IEnumerable resolvers) { - private readonly IReadOnlyList _resolvers; + _resolvers = resolvers.ToList(); + } - public CompositeSessionIdResolver(IEnumerable resolvers) + public AuthSessionId? Resolve(HttpContext context) + { + foreach (var resolver in _resolvers) { - _resolvers = resolvers.ToList(); + var id = resolver.Resolve(context); + if (id is not null) + return id; } - public AuthSessionId? Resolve(HttpContext context) - { - foreach (var resolver in _resolvers) - { - var id = resolver.Resolve(context); - if (id is not null) - return id; - } - - return null; - } + return null; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs index 3c905b1c..c1bab7c3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs @@ -3,33 +3,32 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class CookieSessionIdResolver : IInnerSessionIdResolver { - public sealed class CookieSessionIdResolver : IInnerSessionIdResolver - { - public string Key => "cookie"; + public string Key => "cookie"; - private readonly UAuthServerOptions _options; + private readonly UAuthServerOptions _options; - public CookieSessionIdResolver(IOptions options) - { - _options = options.Value; - } + public CookieSessionIdResolver(IOptions options) + { + _options = options.Value; + } - public AuthSessionId? Resolve(HttpContext context) - { - var cookieName = _options.Cookie.Session.Name; + public AuthSessionId? Resolve(HttpContext context) + { + var cookieName = _options.Cookie.Session.Name; - if (!context.Request.Cookies.TryGetValue(cookieName, out var raw)) - return null; + if (!context.Request.Cookies.TryGetValue(cookieName, out var raw)) + return null; - if (string.IsNullOrWhiteSpace(raw)) - return null; + if (string.IsNullOrWhiteSpace(raw)) + return null; - if (!AuthSessionId.TryCreate(raw, out var sessionId)) - return null; + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; - return sessionId; - } + return sessionId; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs index fe40bc17..7c1bca18 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs @@ -3,32 +3,31 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class HeaderSessionIdResolver : IInnerSessionIdResolver { - public sealed class HeaderSessionIdResolver : IInnerSessionIdResolver - { - public string Key => "header"; - private readonly UAuthServerOptions _options; + public string Key => "header"; + private readonly UAuthServerOptions _options; - public HeaderSessionIdResolver(IOptions options) - { - _options = options.Value; - } + 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; + 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; + var raw = values.FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(raw)) + return null; - if (!AuthSessionId.TryCreate(raw, out var sessionId)) - return null; + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; - return sessionId; - } + return sessionId; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs index 861c72a0..5ef9dc1f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs @@ -1,11 +1,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IInnerSessionIdResolver { - public interface IInnerSessionIdResolver - { - string Key { get; } - AuthSessionId? Resolve(HttpContext context); - } + string Key { 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 index 46c063b6..f46bbff3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface ISessionIdResolver { - public interface ISessionIdResolver - { - AuthSessionId? Resolve(HttpContext context); - } + AuthSessionId? Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs index bb3cc9e1..2082c612 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs @@ -3,33 +3,32 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class QuerySessionIdResolver : IInnerSessionIdResolver - { - public string Key => "query"; - private readonly UAuthServerOptions _options; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; - public QuerySessionIdResolver(IOptions options) - { - _options = options.Value; - } +public sealed class QuerySessionIdResolver : IInnerSessionIdResolver +{ + public string Key => "query"; + private readonly UAuthServerOptions _options; - public AuthSessionId? Resolve(HttpContext context) - { - if (!context.Request.Query.TryGetValue(_options.SessionResolution.QueryParameterName, out var values)) - return null; + public QuerySessionIdResolver(IOptions options) + { + _options = options.Value; + } - var raw = values.FirstOrDefault(); + public AuthSessionId? Resolve(HttpContext context) + { + if (!context.Request.Query.TryGetValue(_options.SessionResolution.QueryParameterName, out var values)) + return null; - if (string.IsNullOrWhiteSpace(raw)) - return null; + var raw = values.FirstOrDefault(); - if (!AuthSessionId.TryCreate(raw, out var sessionId)) - return null; + if (string.IsNullOrWhiteSpace(raw)) + return null; - return sessionId; - } + 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 index c6e526a6..3794ea24 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class SystemClock : IClock { - public sealed class SystemClock : IClock - { - public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; - } + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs index dcd8c17a..cf780253 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs @@ -4,22 +4,20 @@ using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed class HttpContextCurrentUser : ICurrentUser - { - private readonly IHttpContextAccessor _http; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; - public HttpContextCurrentUser(IHttpContextAccessor http) - { - _http = http; - } +internal sealed class HttpContextCurrentUser : ICurrentUser +{ + private readonly IHttpContextAccessor _http; - public bool IsAuthenticated => Snapshot?.IsAuthenticated == true; + public HttpContextCurrentUser(IHttpContextAccessor http) + { + _http = http; + } - public UserKey UserKey => Snapshot?.UserId ?? throw new InvalidOperationException("Current user is not authenticated."); + public bool IsAuthenticated => Snapshot?.IsAuthenticated == true; - private AuthUserSnapshot? Snapshot => _http.HttpContext?.Items[UserMiddleware.UserContextKey] as AuthUserSnapshot; - } + public UserKey UserKey => Snapshot?.UserId ?? throw new InvalidOperationException("Current user is not authenticated."); + private AuthUserSnapshot? Snapshot => _http.HttpContext?.Items[UserMiddleware.UserContextKey] as AuthUserSnapshot; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs index 4df17305..0f14a8ab 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs @@ -1,14 +1,13 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUserAccessor { - public interface IUserAccessor - { - Task ResolveAsync(HttpContext context); - } + Task ResolveAsync(HttpContext context); +} - 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 index 34f76772..9a7098a0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs @@ -1,45 +1,48 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UAuthUserAccessor : IUserAccessor { - public sealed class UAuthUserAccessor : IUserAccessor + private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly IUserIdConverter _userIdConverter; + + public UAuthUserAccessor(ISessionStoreKernelFactory kernelFactory, IUserIdConverterResolver converterResolver) + { + _kernelFactory = kernelFactory; + _userIdConverter = converterResolver.GetConverter(); + } + + public async Task ResolveAsync(HttpContext context) { - private readonly ISessionStore _sessionStore; - private readonly IUserIdConverter _userIdConverter; + var sessionCtx = context.GetSessionContext(); - public UAuthUserAccessor( - ISessionStore sessionStore, - IUserIdConverterResolver converterResolver) + if (sessionCtx.IsAnonymous || sessionCtx.SessionId is null) { - _sessionStore = sessionStore; - _userIdConverter = converterResolver.GetConverter(); + context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Anonymous(); + return; } - public async Task ResolveAsync(HttpContext context) + if (sessionCtx.Tenant is not TenantKey tenant) { - var sessionCtx = context.GetSessionContext(); - - if (sessionCtx.IsAnonymous) - { - context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Anonymous(); - return; - } - - var session = await _sessionStore.GetSessionAsync(sessionCtx.TenantId, sessionCtx.SessionId!.Value); + throw new InvalidOperationException("Tenant context is missing."); + } - if (session is null || session.IsRevoked) - { - context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Anonymous(); - return; - } + var kernel = _kernelFactory.Create(tenant); + var session = await kernel.GetSessionAsync(sessionCtx.SessionId.Value); - var userId = _userIdConverter.FromString(session.UserKey.Value); - context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Authenticated(userId); + if (session is null || session.IsRevoked) + { + context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Anonymous(); + return; } + var userId = _userIdConverter.FromString(session.UserKey.Value); + context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Authenticated(userId); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs index d42d35b3..caf8cb45 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs @@ -1,12 +1,11 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public readonly record struct UAuthUserId(Guid Value) { - public readonly record struct UAuthUserId(Guid Value) - { - public override string ToString() => Value.ToString("N"); + public override string ToString() => Value.ToString("N"); - public static UAuthUserId New() => new(Guid.NewGuid()); + 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); - } + 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 index 3fabb84e..2bacd92e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs @@ -2,22 +2,21 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - internal sealed class UserAccessorBridge : IUserAccessor - { - private readonly IServiceProvider _services; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; - public UserAccessorBridge(IServiceProvider services) - { - _services = services; - } +internal sealed class UserAccessorBridge : IUserAccessor +{ + private readonly IServiceProvider _services; - public async Task ResolveAsync(HttpContext context) - { - var accessor = _services.GetRequiredService>(); - await accessor.ResolveAsync(context); - } + 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/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs deleted file mode 100644 index a10a2b19..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs +++ /dev/null @@ -1,179 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Abstractions; -using CodeBeam.UltimateAuth.Server.Cookies; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; -using System.Security; - -namespace CodeBeam.UltimateAuth.Server.Issuers -{ - public sealed class UAuthSessionIssuer : IHttpSessionIssuer - { - private readonly ISessionStore _sessionStore; - private readonly IOpaqueTokenGenerator _opaqueGenerator; - private readonly UAuthServerOptions _options; - private readonly IUAuthCookieManager _cookieManager; - - public UAuthSessionIssuer(ISessionStore sessionStore, IOpaqueTokenGenerator opaqueGenerator, IOptions options, IUAuthCookieManager cookieManager) - { - _sessionStore = sessionStore; - _opaqueGenerator = opaqueGenerator; - _options = options.Value; - _cookieManager = cookieManager; - } - - public Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) - { - return IssueLoginInternalAsync(httpContext: null, context, ct); - } - - public Task IssueLoginSessionAsync(HttpContext httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) - { - if (httpContext is null) - throw new ArgumentNullException(nameof(httpContext)); - - return IssueLoginInternalAsync(httpContext, context, ct); - } - - private async Task IssueLoginInternalAsync(HttpContext? httpContext, AuthenticatedSessionContext context, CancellationToken ct = default) - { - // Defensive guard — enforcement belongs to Authority - if (_options.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 session = UAuthSession.Create( - sessionId: sessionId, - tenantId: context.TenantId, - userKey: context.UserKey, - chainId: SessionChainId.Unassigned, - now: now, - expiresAt: expiresAt, - claims: context.Claims, - device: context.Device, - metadata: context.Metadata - ); - - var issued = new IssuedSession - { - Session = session, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid - }; - - await _sessionStore.CreateSessionAsync(issued, - new SessionStoreContext - { - TenantId = context.TenantId, - UserKey = context.UserKey, - ChainId = context.ChainId, - IssuedAt = now, - Device = context.Device - }, - ct - ); - - return issued; - } - - public Task RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) - { - return RotateInternalAsync(httpContext: null, context, ct); - } - - public Task RotateSessionAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) - { - if (httpContext is null) - throw new ArgumentNullException(nameof(httpContext)); - - return RotateInternalAsync(httpContext, context, ct); - } - - private async Task RotateInternalAsync(HttpContext httpContext, SessionRotationContext context, CancellationToken ct = default) - { - 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; - } - - var issued = new IssuedSession - { - Session = UAuthSession.Create( - sessionId: newSessionId, - tenantId: context.TenantId, - userKey: context.UserKey, - chainId: SessionChainId.Unassigned, - now: now, - expiresAt: expiresAt, - device: context.Device, - claims: context.Claims, - metadata: context.Metadata - ), - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid - }; - - await _sessionStore.RotateSessionAsync(context.CurrentSessionId, issued, - new SessionStoreContext - { - TenantId = context.TenantId, - UserKey = context.UserKey, - IssuedAt = now, - Device = context.Device, - }, - ct - ); - - return issued; - } - - public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) - { - await _sessionStore.RevokeSessionAsync(tenantId, sessionId, at, ct ); - } - - public async Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) - { - await _sessionStore.RevokeChainAsync(tenantId, chainId, at, ct ); - } - - public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) - { - await _sessionStore.RevokeAllChainsAsync(tenantId, userKey, exceptChainId, at, ct ); - } - - public async Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) - { - await _sessionStore.RevokeRootAsync(tenantId, userKey, at, ct ); - } - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs deleted file mode 100644 index ad22a080..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs +++ /dev/null @@ -1,145 +0,0 @@ -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.Issuers -{ - /// - /// 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 IRefreshTokenStore _refreshTokenStore; - private readonly IUserIdConverterResolver _converterResolver; - private readonly IClock _clock; - - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore,IUserIdConverterResolver converterResolver, IClock clock) - { - _opaqueGenerator = opaqueGenerator; - _jwtGenerator = jwtGenerator; - _tokenHasher = tokenHasher; - _refreshTokenStore = refreshTokenStore; - _converterResolver = converterResolver; - _clock = clock; - } - - public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) - { - var tokens = flow.OriginalOptions.Tokens; - 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; - - var expires = _clock.UtcNow.Add(flow.OriginalOptions.Tokens.RefreshTokenLifetime); - - var raw = _opaqueGenerator.Generate(); - var hash = _tokenHasher.Hash(raw); - - var stored = new StoredRefreshToken - { - TenantId = flow.TenantId, - TokenHash = hash, - UserKey = context.UserKey, - // TODO: Check here again - SessionId = (AuthSessionId)context.SessionId, - ChainId = context.ChainId, - IssuedAt = _clock.UtcNow, - ExpiresAt = expires - }; - - if (persistence == RefreshTokenPersistence.Persist) - { - await _refreshTokenStore.StoreAsync(flow.TenantId, stored, ct); - } - - return new RefreshToken - { - 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, - ["tenant"] = context.TenantId - }; - - 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.Generate(16); - - var descriptor = new UAuthJwtTokenDescriptor - { - Subject = context.UserKey, - Issuer = tokens.Issuer, - Audience = tokens.Audience, - IssuedAt = _clock.UtcNow, - ExpiresAt = expires, - TenantId = context.TenantId, - 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/Login/DefaultLoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs deleted file mode 100644 index 7836ef64..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Login -{ - /// - /// Default implementation of the login authority. - /// Applies basic security checks for login attempts. - /// - public sealed class DefaultLoginAuthority : ILoginAuthority - { - public LoginDecision Decide(LoginDecisionContext context) - { - if (!context.CredentialsValid) - { - return LoginDecision.Deny("Invalid credentials."); - } - - if (!context.UserExists || context.UserKey is null) - { - return LoginDecision.Deny("Invalid credentials."); - } - - var state = context.SecurityState; - if (state is not null) - { - if (state.IsLocked) - return LoginDecision.Deny("user_is_locked"); - - if (state.RequiresReauthentication) - return LoginDecision.Challenge("reauth_required"); - } - - return LoginDecision.Allow(); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs deleted file mode 100644 index 028f6608..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs +++ /dev/null @@ -1,169 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -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.Users; -using CodeBeam.UltimateAuth.Users.Abstractions; - -namespace CodeBeam.UltimateAuth.Server.Login.Orchestrators -{ - internal sealed class DefaultLoginOrchestrator : ILoginOrchestrator - { - private readonly ICredentialStore _credentialStore; // authentication - private readonly ICredentialValidator _credentialValidator; - private readonly IUserRuntimeStateProvider _users; // eligible - private readonly IUserSecurityStateProvider _userSecurityStateProvider; // runtime risk - private readonly ILoginAuthority _authority; - private readonly ISessionOrchestrator _sessionOrchestrator; - private readonly ITokenIssuer _tokens; - private readonly IUserClaimsProvider _claimsProvider; - private readonly IUserIdConverterResolver _userIdConverterResolver; - - public DefaultLoginOrchestrator( - ICredentialStore credentialStore, - ICredentialValidator credentialValidator, - IUserRuntimeStateProvider users, - IUserSecurityStateProvider userSecurityStateProvider, - ILoginAuthority authority, - ISessionOrchestrator sessionOrchestrator, - ITokenIssuer tokens, - IUserClaimsProvider claimsProvider, - IUserIdConverterResolver userIdConverterResolver) - { - _credentialStore = credentialStore; - _credentialValidator = credentialValidator; - _users = users; - _userSecurityStateProvider = userSecurityStateProvider; - _authority = authority; - _sessionOrchestrator = sessionOrchestrator; - _tokens = tokens; - _claimsProvider = claimsProvider; - _userIdConverterResolver = userIdConverterResolver; - } - - public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var now = request.At ?? DateTimeOffset.UtcNow; - - var credentials = await _credentialStore.FindByLoginAsync(request.TenantId, request.Identifier, ct); - var orderedCredentials = credentials - .OfType() - .Where(c => c.Security.IsUsable(now)) - .Cast>() - .ToList(); - - TUserId validatedUserId = default!; - bool credentialsValid = false; - - foreach (var credential in orderedCredentials) - { - var result = await _credentialValidator.ValidateAsync(credential, request.Secret, ct); - - if (result.IsValid) - { - validatedUserId = credential.UserId; - credentialsValid = true; - break; - } - } - - bool userExists = credentialsValid; - - IUserSecurityState? securityState = null; - UserKey? userKey = null; - - if (credentialsValid) - { - securityState = await _userSecurityStateProvider.GetAsync(request.TenantId, validatedUserId, ct); - var converter = _userIdConverterResolver.GetConverter(); - userKey = UserKey.FromString(converter.ToString(validatedUserId)); - } - - var user = userKey is not null - ? await _users.GetAsync(request.TenantId, userKey.Value, ct) - : null; - - if (user is null || user.IsDeleted || !user.IsActive) - { - // Deliberately vague - return LoginResult.Failed(); - } - - var decisionContext = new LoginDecisionContext - { - TenantId = request.TenantId, - Identifier = request.Identifier, - CredentialsValid = credentialsValid, - UserExists = userExists, - UserKey = userKey, - SecurityState = securityState, - IsChained = request.ChainId is not null - }; - - var decision = _authority.Decide(decisionContext); - - if (decision.Kind == LoginDecisionKind.Deny) - return LoginResult.Failed(); - - if (decision.Kind == LoginDecisionKind.Challenge) - { - return LoginResult.Continue(new LoginContinuation - { - Type = LoginContinuationType.Mfa, - Hint = decision.Reason - }); - } - - if (userKey is not UserKey validUserKey) - { - return LoginResult.Failed(); - } - - var claims = await _claimsProvider.GetClaimsAsync(request.TenantId, validUserKey, ct); - - var sessionContext = new AuthenticatedSessionContext - { - TenantId = request.TenantId, - UserKey = validUserKey, - Now = now, - Device = request.Device, - Claims = claims, - ChainId = request.ChainId, - Metadata = SessionMetadata.Empty - }; - - 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 - { - TenantId = request.TenantId, - UserKey = validUserKey, - SessionId = issuedSession.Session.SessionId, - ChainId = request.ChainId, - Claims = claims.AsDictionary() - }; - - tokens = new AuthTokens - { - AccessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct), - RefreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct) - }; - } - - return LoginResult.Success(issuedSession.Session.SessionId, tokens); - - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs deleted file mode 100644 index d05fac67..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Login -{ - /// - /// 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/Login/ILoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs deleted file mode 100644 index d22d605b..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Auth; - -namespace CodeBeam.UltimateAuth.Server.Login -{ - /// - /// 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); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs deleted file mode 100644 index 625d3113..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Login -{ - /// - /// Represents the outcome of a login decision. - /// - public sealed class LoginDecision - { - public LoginDecisionKind Kind { get; } - public string? Reason { get; } - - private LoginDecision(LoginDecisionKind kind, string? reason = null) - { - Kind = kind; - Reason = reason; - } - - public static LoginDecision Allow() - => new(LoginDecisionKind.Allow); - - public static LoginDecision Deny(string reason) - => new(LoginDecisionKind.Deny, reason); - - public static LoginDecision Challenge(string reason) - => new(LoginDecisionKind.Challenge, reason); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs deleted file mode 100644 index 695d19d2..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs +++ /dev/null @@ -1,50 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users; - -namespace CodeBeam.UltimateAuth.Server.Login -{ - /// - /// Represents all information required by the login authority - /// to make a login decision. - /// - public sealed class LoginDecisionContext - { - /// - /// Gets the tenant identifier. - /// - public string? TenantId { 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; } - - /// - /// 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/Login/LoginDecisionKind.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs deleted file mode 100644 index c1086d05..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Login -{ - public enum LoginDecisionKind - { - Allow = 1, - Deny = 2, - Challenge = 3 - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index cbd53bc5..51d3eb3e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -4,33 +4,30 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Server.Middlewares +namespace CodeBeam.UltimateAuth.Server.Middlewares; + +public sealed class SessionResolutionMiddleware { - public sealed class SessionResolutionMiddleware - { - private readonly RequestDelegate _next; + private readonly RequestDelegate _next; - public SessionResolutionMiddleware(RequestDelegate next) - { - _next = next; - } + public SessionResolutionMiddleware(RequestDelegate next) + { + _next = next; + } - public async Task InvokeAsync(HttpContext context) - { - var sessionIdResolver = context.RequestServices.GetRequiredService(); + public async Task InvokeAsync(HttpContext context) + { + var sessionIdResolver = context.RequestServices.GetRequiredService(); - var tenant = context.GetTenantContext(); - var sessionId = sessionIdResolver.Resolve(context); + var tenant = context.GetTenant(); + var sessionId = sessionIdResolver.Resolve(context); - var sessionContext = sessionId is null - ? SessionContext.Anonymous() - : SessionContext.FromSessionId( - sessionId.Value, - tenant.TenantId); + var sessionContext = sessionId is null + ? SessionContext.Anonymous() + : SessionContext.FromSessionId(sessionId.Value, tenant); - context.Items[SessionContextItemKeys.SessionContext] = sessionContext; + context.Items[SessionContextItemKeys.SessionContext] = sessionContext; - await _next(context); - } + await _next(context); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index a9b82ffb..1f9e4a4d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -4,44 +4,49 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Middlewares +namespace CodeBeam.UltimateAuth.Server.Middlewares; + +public sealed class TenantMiddleware { - public sealed class TenantMiddleware + private readonly RequestDelegate _next; + public const string TenantContextKey = "__UAuthTenant"; + + public TenantMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - public const string TenantContextKey = "__UAuthTenant"; + public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOptions options) + { + var opts = options.Value; + TenantResolutionResult resolution; - public TenantMiddleware(RequestDelegate next) + if (!opts.Enabled) { - _next = next; + context.Items[TenantContextKey] = UAuthTenantContext.SingleTenant(); + await _next(context); + return; } - public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOptions options) - { - var opts = options.Value; - - UAuthTenantContext tenantContext; + resolution = await resolver.ResolveAsync(context); - if (!opts.Enabled) - { - tenantContext = UAuthTenantContext.NotResolved(); - } - else + if (!resolution.IsResolved) + { + if (opts.RequireTenant) { - tenantContext = await resolver.ResolveAsync(context); - - if (opts.RequireTenant && !tenantContext.IsResolved) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Tenant is required but could not be resolved."); - return; - } + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Tenant is required."); + return; } - context.Items[TenantContextKey] = tenantContext; - - await _next(context); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Tenant could not be resolved."); + return; } + + var tenantContext = UAuthTenantContext.Resolved(resolution.Tenant); + + context.Items[TenantContextKey] = tenantContext; + await _next(context); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs index 77a41e16..46e9b6c2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs @@ -2,24 +2,23 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Server.Middlewares +namespace CodeBeam.UltimateAuth.Server.Middlewares; + +public sealed class UserMiddleware { - public sealed class UserMiddleware - { - private readonly RequestDelegate _next; + private readonly RequestDelegate _next; - public const string UserContextKey = "__UAuthUser"; + public const string UserContextKey = "__UAuthUser"; - public UserMiddleware(RequestDelegate next) - { - _next = 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); - } + 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 index 60b1223a..8cf41528 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs @@ -1,11 +1,9 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.MultiTenancy +namespace CodeBeam.UltimateAuth.Server.MultiTenancy; + +public interface ITenantResolver { - public interface ITenantResolver - { - Task ResolveAsync(HttpContext ctx); - } + Task ResolveAsync(HttpContext context); } - diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs index ae7457a9..66341adf 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs @@ -1,31 +1,21 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.MultiTenancy +namespace CodeBeam.UltimateAuth.Server.MultiTenancy; + +public static class TenantResolutionContextFactory { - public static class TenantResolutionContextFactory + public static TenantResolutionContext FromHttpContext(HttpContext ctx) { - 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 - ); - } + 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 index c9531f0e..be3e7fc5 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs @@ -1,22 +1,28 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; -using System.Text.RegularExpressions; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy; public static class UAuthTenantContextFactory { - public static UAuthTenantContext Create( - string tenantId, - UAuthMultiTenantOptions options) + public static UAuthTenantContext Create(string? rawTenantId, UAuthMultiTenantOptions options) { - if (options.NormalizeToLowercase) - tenantId = tenantId.ToLowerInvariant(); + if (!options.Enabled) + return UAuthTenantContext.SingleTenant(); + + if (string.IsNullOrWhiteSpace(rawTenantId)) + { + if (options.RequireTenant) + throw new InvalidOperationException("Tenant is required but could not be resolved."); - if (!Regex.IsMatch(tenantId, options.TenantIdRegex)) - return UAuthTenantContext.NotResolved(); + throw new InvalidOperationException("Tenant could not be resolved."); + } - if (options.ReservedTenantIds.Contains(tenantId)) - return UAuthTenantContext.NotResolved(); + var tenantId = options.NormalizeToLowercase + ? rawTenantId.Trim().ToLowerInvariant() + : rawTenantId.Trim(); - return UAuthTenantContext.Resolved(tenantId); + 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 index ddbe0eb4..028f16ed 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -3,74 +3,36 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.MultiTenancy -{ - /// - /// Server-level tenant resolver. - /// Responsible for executing core tenant id resolvers and - /// applying UltimateAuth tenant policies. - /// - 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) - { - if (!_options.Enabled) - return UAuthTenantContext.NotResolved(); - - var resolutionContext = - TenantResolutionContextFactory.FromHttpContext(context); - - var rawTenantId = - await _idResolver.ResolveTenantIdAsync(resolutionContext); +namespace CodeBeam.UltimateAuth.Server.MultiTenancy; - if (string.IsNullOrWhiteSpace(rawTenantId)) - { - if (_options.RequireTenant) - return UAuthTenantContext.NotResolved(); - - if (_options.DefaultTenantId is null) - return UAuthTenantContext.NotResolved(); - - return UAuthTenantContext.Resolved( - Normalize(_options.DefaultTenantId)); - } +public sealed class UAuthTenantResolver : ITenantResolver +{ + private readonly ITenantIdResolver _idResolver; + private readonly UAuthMultiTenantOptions _options; - var tenantId = Normalize(rawTenantId); + public UAuthTenantResolver(ITenantIdResolver idResolver, IOptions options) + { + _idResolver = idResolver; + _options = options.Value; + } - if (!IsValid(tenantId)) - return UAuthTenantContext.NotResolved(); + public async Task ResolveAsync(HttpContext context) + { + var resolutionContext = + TenantResolutionContextFactory.FromHttpContext(context); - return UAuthTenantContext.Resolved(tenantId); - } + var raw = await _idResolver.ResolveTenantIdAsync(resolutionContext); - private string Normalize(string tenantId) - { - return _options.NormalizeToLowercase - ? tenantId.ToLowerInvariant() - : tenantId; - } + if (string.IsNullOrWhiteSpace(raw)) + return TenantResolutionResult.NotResolved(); - private bool IsValid(string tenantId) - { - if (!System.Text.RegularExpressions.Regex - .IsMatch(tenantId, _options.TenantIdRegex)) - return false; + var normalized = _options.NormalizeToLowercase + ? raw.Trim().ToLowerInvariant() + : raw.Trim(); - if (_options.ReservedTenantIds.Contains(tenantId)) - return false; + if (!TenantKey.TryParse(normalized, null, out var tenant)) + return TenantResolutionResult.NotResolved(); - return true; - } + return TenantResolutionResult.Resolved(tenant); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs index 41d5a0d1..0c9dda0e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs @@ -1,22 +1,21 @@ -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class AuthResponseOptions { - public sealed class AuthResponseOptions - { - public CredentialResponseOptions SessionIdDelivery { get; set; } = new(); - public CredentialResponseOptions AccessTokenDelivery { get; set; } = new(); - public CredentialResponseOptions RefreshTokenDelivery { get; set; } = new(); + 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(); + public LoginRedirectOptions Login { get; set; } = new(); + public LogoutRedirectOptions Logout { get; set; } = new(); - internal AuthResponseOptions Clone() => new() - { - SessionIdDelivery = SessionIdDelivery.Clone(), - AccessTokenDelivery = AccessTokenDelivery.Clone(), - RefreshTokenDelivery = RefreshTokenDelivery.Clone(), - Login = Login.Clone(), - Logout = Logout.Clone() - }; + internal AuthResponseOptions 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/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs index ba0ef111..9419dd2c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -2,46 +2,45 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class CredentialResponseOptions { - public sealed class CredentialResponseOptions + public CredentialKind 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) + => new() { - public CredentialKind 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) - => new() - { - Kind = Kind, - Mode = Mode, - Name = Name, - HeaderFormat = HeaderFormat, - TokenFormat = TokenFormat, - Cookie = cookie - }; - - } + Kind = Kind, + Mode = Mode, + Name = Name, + HeaderFormat = HeaderFormat, + TokenFormat = TokenFormat, + Cookie = cookie + }; + } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs index e96fbef0..8a8ae0aa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -2,140 +2,139 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Options; -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Server.Options; + +internal class ConfigureDefaults { - internal class ConfigureDefaults + internal static void ApplyModeDefaults(UAuthServerOptions o) { - internal static void ApplyModeDefaults(UAuthServerOptions o) + switch (o.Mode) { - switch (o.Mode) - { - 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: {o.Mode}"); - } - } + case UAuthMode.PureOpaque: + ApplyPureOpaqueDefaults(o); + break; - private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) - { - var s = o.Session; - var t = o.Tokens; - var c = o.Cookie; - var r = o.AuthResponse; + case UAuthMode.Hybrid: + ApplyHybridDefaults(o); + break; - // Session behavior - s.SlidingExpiration = true; + case UAuthMode.SemiHybrid: + ApplySemiHybridDefaults(o); + break; - // Default: long-lived idle session (UX friendly) - s.IdleTimeout ??= TimeSpan.FromDays(7); + case UAuthMode.PureJwt: + ApplyPureJwtDefaults(o); + break; - s.TouchInterval ??= TimeSpan.FromDays(1); + default: + throw new InvalidOperationException($"Unsupported UAuthMode: {o.Mode}"); + } + } - // Hard re-auth boundary is an advanced security feature - // Do NOT enable by default - s.MaxLifetime ??= null; - s.DeviceMismatchBehavior = DeviceMismatchBehavior.Allow; + private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + var c = o.Cookie; + var r = o.AuthResponse; - // SessionId is the primary opaque token, carried via cookie - t.IssueJwt = false; + // Session behavior + s.SlidingExpiration = true; - // No separate opaque access token is issued outside the session cookie - t.IssueOpaque = false; + // Default: long-lived idle session (UX friendly) + s.IdleTimeout ??= TimeSpan.FromDays(7); - // Refresh token does not exist in PureOpaque - t.IssueRefresh = false; + s.TouchInterval ??= TimeSpan.FromDays(1); - c.Session.Lifetime.IdleBuffer = TimeSpan.FromDays(2); + // Hard re-auth boundary is an advanced security feature + // Do NOT enable by default + s.MaxLifetime ??= null; + s.DeviceMismatchBehavior = DeviceMismatchBehavior.Allow; - r.RefreshTokenDelivery = new CredentialResponseOptions - { - Mode = TokenResponseMode.None, - TokenFormat = TokenFormat.Opaque - }; - } + // SessionId is the primary opaque token, carried via cookie + t.IssueJwt = false; - private static void ApplyHybridDefaults(UAuthServerOptions o) - { - var s = o.Session; - var t = o.Tokens; - 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 - }; - } + // No separate opaque access token is issued outside the session cookie + t.IssueOpaque = false; + + // Refresh token does not exist in PureOpaque + t.IssueRefresh = false; - private static void ApplySemiHybridDefaults(UAuthServerOptions o) + c.Session.Lifetime.IdleBuffer = TimeSpan.FromDays(2); + + r.RefreshTokenDelivery = new CredentialResponseOptions { - var s = o.Session; - var t = o.Tokens; - 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); - } + Mode = TokenResponseMode.None, + TokenFormat = TokenFormat.Opaque + }; + } - private static void ApplyPureJwtDefaults(UAuthServerOptions o) + private static void ApplyHybridDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + 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 { - var s = o.Session; - var t = o.Tokens; - var p = o.Pkce; - var c = o.Cookie; + Mode = TokenResponseMode.Cookie, + TokenFormat = TokenFormat.Opaque + }; + } - s.TouchInterval = null; + private static void ApplySemiHybridDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Tokens; + 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.Tokens; + var p = o.Pkce; + var c = o.Cookie; - o.Session.SlidingExpiration = false; - o.Session.IdleTimeout = null; - o.Session.MaxLifetime = null; + s.TouchInterval = null; - t.IssueJwt = true; - t.IssueOpaque = false; - t.AccessTokenLifetime = TimeSpan.FromMinutes(10); - t.RefreshTokenLifetime = TimeSpan.FromDays(7); - t.AddJwtIdClaim = true; + o.Session.SlidingExpiration = false; + o.Session.IdleTimeout = null; + o.Session.MaxLifetime = null; - c.AccessToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); - c.RefreshToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); - } + 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 index 05a20b9e..a2b7f955 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs @@ -3,11 +3,10 @@ using CodeBeam.UltimateAuth.Server.Auth; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Server.Options; + +public interface IEffectiveServerOptionsProvider { - public interface IEffectiveServerOptionsProvider - { - UAuthServerOptions GetOriginal(HttpContext context); - EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowType flowType, UAuthClientProfile clientProfile); - } + UAuthServerOptions GetOriginal(HttpContext context); + EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowType flowType, UAuthClientProfile clientProfile); } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs index e4968027..b542d2aa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs @@ -1,28 +1,26 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Options -{ - public sealed class LoginRedirectOptions - { - public bool RedirectEnabled { get; set; } = true; +namespace CodeBeam.UltimateAuth.Server.Options; - public string SuccessRedirect { get; init; } = "/"; - public string FailureRedirect { get; init; } = "/login"; +public sealed class LoginRedirectOptions +{ + public bool RedirectEnabled { get; set; } = true; - public string FailureQueryKey { get; init; } = "error"; - public string CodeQueryKey { get; set; } = "code"; + public string SuccessRedirect { get; init; } = "/"; + public string FailureRedirect { get; init; } = "/login"; - public Dictionary FailureCodes { get; set; } = new(); + public string FailureQueryKey { get; init; } = "error"; + public string CodeQueryKey { get; set; } = "code"; - internal LoginRedirectOptions Clone() => new() - { - RedirectEnabled = RedirectEnabled, - SuccessRedirect = SuccessRedirect, - FailureRedirect = FailureRedirect, - FailureQueryKey = FailureQueryKey, - CodeQueryKey = CodeQueryKey, - FailureCodes = new Dictionary(FailureCodes) - }; + public Dictionary FailureCodes { get; set; } = new(); - } + internal LoginRedirectOptions Clone() => new() + { + RedirectEnabled = RedirectEnabled, + SuccessRedirect = SuccessRedirect, + FailureRedirect = FailureRedirect, + FailureQueryKey = FailureQueryKey, + CodeQueryKey = CodeQueryKey, + FailureCodes = new Dictionary(FailureCodes) + }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs index 90ea5e2f..0126e299 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs @@ -1,28 +1,27 @@ -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class LogoutRedirectOptions { - public sealed class LogoutRedirectOptions - { - /// - /// Whether logout endpoint performs a redirect. - /// - public bool RedirectEnabled { get; set; } = true; + /// + /// Whether logout endpoint performs a redirect. + /// + public bool RedirectEnabled { get; set; } = true; - /// - /// Default redirect URL after logout. - /// - public string RedirectUrl { get; set; } = "/login"; + /// + /// Default redirect URL after logout. + /// + public string RedirectUrl { get; set; } = "/login"; - /// - /// Whether query-based returnUrl override is allowed. - /// - public bool AllowReturnUrlOverride { get; set; } = true; + /// + /// Whether query-based returnUrl override is allowed. + /// + public bool AllowReturnUrlOverride { get; set; } = true; - internal LogoutRedirectOptions Clone() => new() - { - RedirectEnabled = RedirectEnabled, - RedirectUrl = RedirectUrl, - AllowReturnUrlOverride = AllowReturnUrlOverride - }; + internal LogoutRedirectOptions Clone() => new() + { + RedirectEnabled = RedirectEnabled, + RedirectUrl = RedirectUrl, + AllowReturnUrlOverride = AllowReturnUrlOverride + }; - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs index 77dd2055..dd143c9e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs @@ -1,24 +1,22 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Options -{ - public sealed class PrimaryCredentialPolicy - { - /// - /// Default primary credential for UI-style requests. - /// - public PrimaryCredentialKind Ui { get; set; } = PrimaryCredentialKind.Stateful; +namespace CodeBeam.UltimateAuth.Server.Options; - /// - /// Default primary credential for API requests. - /// - public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; +public sealed class PrimaryCredentialPolicy +{ + /// + /// Default primary credential for UI-style requests. + /// + public PrimaryCredentialKind Ui { get; set; } = PrimaryCredentialKind.Stateful; - internal PrimaryCredentialPolicy Clone() => new() - { - Ui = Ui, - Api = Api - }; + /// + /// Default primary credential for API requests. + /// + public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; - } + internal PrimaryCredentialPolicy Clone() => new() + { + Ui = Ui, + Api = Api + }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs index 5a1af3fe..41fdc042 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs @@ -1,25 +1,22 @@ -using System.Xml.Linq; +namespace CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Options +public sealed class UAuthCookieLifetimeOptions { - 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); + /// + /// 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; } + /// + /// 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 - }; - } + internal UAuthCookieLifetimeOptions Clone() => new() + { + IdleBuffer = IdleBuffer, + AbsoluteLifetimeOverride = AbsoluteLifetimeOverride + }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs index b0f73628..56755f2b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs @@ -1,40 +1,39 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthCookieSetOptions { - public sealed class UAuthCookieSetOptions - { - public bool EnableSessionCookie { get; set; } = true; - public bool EnableAccessTokenCookie { get; set; } = true; - public bool EnableRefreshTokenCookie { get; set; } = true; + public bool EnableSessionCookie { get; set; } = true; + public bool EnableAccessTokenCookie { get; set; } = true; + public bool EnableRefreshTokenCookie { get; set; } = true; - public UAuthCookieOptions Session { get; init; } = new() - { - Name = "uas", - HttpOnly = true, - SameSite = SameSiteMode.None - }; + public UAuthCookieOptions Session { get; init; } = new() + { + Name = "uas", + HttpOnly = true, + SameSite = SameSiteMode.None + }; - public UAuthCookieOptions RefreshToken { get; init; } = new() - { - Name = "uar", - HttpOnly = true, - SameSite = SameSiteMode.None - }; + public UAuthCookieOptions RefreshToken { get; init; } = new() + { + Name = "uar", + HttpOnly = true, + SameSite = SameSiteMode.None + }; - public UAuthCookieOptions AccessToken { get; init; } = new() - { - Name = "uat", - HttpOnly = true, - SameSite = SameSiteMode.None - }; + public UAuthCookieOptions AccessToken { get; init; } = new() + { + Name = "uat", + HttpOnly = true, + SameSite = SameSiteMode.None + }; - internal UAuthCookieSetOptions Clone() => new() - { - Session = Session.Clone(), - RefreshToken = RefreshToken.Clone(), - AccessToken = AccessToken.Clone() - }; + internal UAuthCookieSetOptions 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 index 20a83ec3..d8c3023c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs @@ -1,19 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Options; +namespace CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Options +public sealed class UAuthDiagnosticsOptions { - public sealed class UAuthDiagnosticsOptions - { - /// - /// Enables debug / sample-only response headers such as X-UAuth-Refresh. - /// Should be disabled in production. - /// - public bool EnableRefreshHeaders { get; set; } = false; + /// + /// Enables debug / sample-only response headers such as X-UAuth-Refresh. + /// Should be disabled in production. + /// + public bool EnableRefreshHeaders { get; set; } = false; - internal UAuthDiagnosticsOptions Clone() => new() - { - EnableRefreshHeaders = EnableRefreshHeaders - }; + internal UAuthDiagnosticsOptions Clone() => new() + { + EnableRefreshHeaders = EnableRefreshHeaders + }; - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs index 9eefe565..432dc91e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs @@ -1,28 +1,25 @@ -using CodeBeam.UltimateAuth.Core.Options; +namespace CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Options +public sealed class UAuthHubServerOptions { - public sealed class UAuthHubServerOptions - { - public string? ClientBaseAddress { get; set; } + public string? ClientBaseAddress { get; set; } - public HashSet AllowedClientOrigins { get; set; } = new(); + public HashSet AllowedClientOrigins { get; set; } = new(); - /// - /// Lifetime of hub flow artifacts (UI orchestration). - /// Should be short-lived. - /// - public TimeSpan FlowLifetime { get; set; } = TimeSpan.FromMinutes(2); + /// + /// Lifetime of hub flow artifacts (UI orchestration). + /// Should be short-lived. + /// + public TimeSpan FlowLifetime { get; set; } = TimeSpan.FromMinutes(2); - public string? LoginPath { get; set; } = "/login"; + public string? LoginPath { get; set; } = "/login"; - internal UAuthHubServerOptions Clone() => new() - { - ClientBaseAddress = ClientBaseAddress, - AllowedClientOrigins = new HashSet(AllowedClientOrigins), - FlowLifetime = FlowLifetime, - LoginPath = LoginPath - }; + internal UAuthHubServerOptions Clone() => new() + { + ClientBaseAddress = ClientBaseAddress, + AllowedClientOrigins = new HashSet(AllowedClientOrigins), + FlowLifetime = FlowLifetime, + LoginPath = LoginPath + }; - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index ddf940da..b80b1c6a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -4,189 +4,188 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Server.Options +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 { /// - /// 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). + /// Defines how UltimateAuth executes authentication flows. + /// Default is Hybrid. /// - public sealed class UAuthServerOptions + public UAuthMode? Mode { 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; + + // ------------------------------------------------------- + // ROUTING + // ------------------------------------------------------- + + /// + /// Base API route. Default: "/auth" + /// Changing this prevents conflicts with other auth systems. + /// + public string RoutePrefix { get; set; } = "/auth"; + + + // ------------------------------------------------------- + // CORE OPTION COMPOSITION + // (Server must NOT duplicate Core options) + // ------------------------------------------------------- + + /// + /// 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 Tokens { get; set; } = new(); + + /// + /// PKCE configuration (required for WASM). + /// Fully defined in Core. + /// + public UAuthPkceOptions Pkce { get; set; } = new(); + + /// + /// Multi-tenancy behavior (resolver, normalization, etc.) + /// Fully defined in Core. + /// + public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + + /// + /// Allows advanced users to override cookie behavior. + /// Unsafe combinations will be rejected at startup. + /// + public UAuthCookieSetOptions Cookie { get; set; } = new(); + + public UAuthDiagnosticsOptions Diagnostics { get; set; } = new(); + + internal Type? CustomCookieManagerType { get; private set; } + + public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManager { - /// - /// Defines how UltimateAuth executes authentication flows. - /// Default is Hybrid. - /// - public UAuthMode? Mode { 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; - - // ------------------------------------------------------- - // ROUTING - // ------------------------------------------------------- - - /// - /// Base API route. Default: "/auth" - /// Changing this prevents conflicts with other auth systems. - /// - public string RoutePrefix { get; set; } = "/auth"; - - - // ------------------------------------------------------- - // CORE OPTION COMPOSITION - // (Server must NOT duplicate Core options) - // ------------------------------------------------------- - - /// - /// 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 Tokens { get; set; } = new(); - - /// - /// PKCE configuration (required for WASM). - /// Fully defined in Core. - /// - public UAuthPkceOptions Pkce { get; set; } = new(); - - /// - /// Multi-tenancy behavior (resolver, normalization, etc.) - /// Fully defined in Core. - /// - public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); - - /// - /// Allows advanced users to override cookie behavior. - /// Unsafe combinations will be rejected at startup. - /// - public UAuthCookieSetOptions Cookie { get; set; } = new(); - - public UAuthDiagnosticsOptions Diagnostics { get; set; } = new(); - - internal Type? CustomCookieManagerType { get; private set; } - - public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManager - { - CustomCookieManagerType = typeof(T); - } + CustomCookieManagerType = typeof(T); + } - // ------------------------------------------------------- - // SERVER-ONLY BEHAVIOR - // ------------------------------------------------------- + // ------------------------------------------------------- + // SERVER-ONLY BEHAVIOR + // ------------------------------------------------------- - public PrimaryCredentialPolicy PrimaryCredential { get; init; } = new(); + public PrimaryCredentialPolicy PrimaryCredential { get; init; } = new(); - public AuthResponseOptions AuthResponse { get; init; } = new(); + public AuthResponseOptions AuthResponse { get; init; } = new(); - public UAuthHubServerOptions Hub { get; set; } = new(); + public UAuthHubServerOptions 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(); + /// + /// 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 bool? EnableLoginEndpoints { get; set; } = true; - public bool? EnablePkceEndpoints { get; set; } = true; - public bool? EnableTokenEndpoints { get; set; } = true; - public bool? EnableSessionEndpoints { get; set; } = true; - public bool? EnableUserInfoEndpoints { get; set; } = true; + /// + /// Enables/disables specific endpoint groups. + /// Useful for API hardening. + /// + public bool? EnableLoginEndpoints { get; set; } = true; + public bool? EnablePkceEndpoints { get; set; } = true; + public bool? EnableTokenEndpoints { get; set; } = true; + public bool? EnableSessionEndpoints { get; set; } = true; + public bool? EnableUserInfoEndpoints { get; set; } = true; - public bool? EnableUserLifecycleEndpoints { get; set; } = true; - public bool? EnableUserProfileEndpoints { get; set; } = true; - public bool? EnableUserIdentifierEndpoints { get; set; } = true; - public bool? EnableCredentialsEndpoints { get; set; } = true; - public bool? EnableAuthorizationEndpoints { get; set; } = true; + public bool? EnableUserLifecycleEndpoints { get; set; } = true; + public bool? EnableUserProfileEndpoints { get; set; } = true; + public bool? EnableUserIdentifierEndpoints { get; set; } = true; + public bool? EnableCredentialsEndpoints { get; set; } = true; + public bool? EnableAuthorizationEndpoints { get; set; } = true; - public UserIdentifierOptions UserIdentifiers { get; set; } = new(); + public UserIdentifierOptions UserIdentifiers { get; set; } = new(); - /// - /// If true, server will add anti-forgery headers - /// and require proper request metadata. - /// - public bool EnableAntiCsrfProtection { get; set; } = true; + /// + /// 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; + /// + /// If true, login attempts are rate-limited to prevent brute force attacks. + /// + public bool EnableLoginRateLimiting { get; set; } = true; - // ------------------------------------------------------- - // CUSTOMIZATION HOOKS - // ------------------------------------------------------- + // ------------------------------------------------------- + // CUSTOMIZATION HOOKS + // ------------------------------------------------------- - /// - /// Allows developers to mutate endpoint routing AFTER UltimateAuth registers defaults. - /// Example: adding new routes, overriding authorization, adding filters. - /// - public Action? OnConfigureEndpoints { get; set; } + /// + /// Allows developers to mutate endpoint routing AFTER UltimateAuth registers defaults. + /// Example: adding new routes, overriding authorization, adding filters. + /// + public Action? OnConfigureEndpoints { get; set; } - /// - /// Allows developers to add or replace server services before DI is built. - /// Example: overriding default ILoginService. - /// - public Action? ConfigureServices { get; set; } + /// + /// Allows developers to add or replace server services before DI is built. + /// Example: overriding default ILoginService. + /// + public Action? ConfigureServices { get; set; } - internal Dictionary> ModeConfigurations { get; set; } = new(); + internal Dictionary> ModeConfigurations { get; set; } = new(); - internal UAuthServerOptions Clone() + internal UAuthServerOptions Clone() + { + return new UAuthServerOptions { - return new UAuthServerOptions - { - Mode = Mode, - HubDeploymentMode = HubDeploymentMode, - RoutePrefix = RoutePrefix, - - Session = Session.Clone(), - Tokens = Tokens.Clone(), - Pkce = Pkce.Clone(), - MultiTenant = MultiTenant.Clone(), - Cookie = Cookie.Clone(), - Diagnostics = Diagnostics.Clone(), - - PrimaryCredential = PrimaryCredential.Clone(), - AuthResponse = AuthResponse.Clone(), - Hub = Hub.Clone(), - SessionResolution = SessionResolution.Clone(), - UserIdentifiers = UserIdentifiers.Clone(), - - EnableLoginEndpoints = EnableLoginEndpoints, - EnablePkceEndpoints = EnablePkceEndpoints, - EnableTokenEndpoints = EnableTokenEndpoints, - EnableSessionEndpoints = EnableSessionEndpoints, - EnableUserInfoEndpoints = EnableUserInfoEndpoints, - EnableUserLifecycleEndpoints = EnableUserLifecycleEndpoints, - EnableUserProfileEndpoints = EnableUserProfileEndpoints, - EnableCredentialsEndpoints = EnableCredentialsEndpoints, - EnableAuthorizationEndpoints = EnableAuthorizationEndpoints, - - EnableAntiCsrfProtection = EnableAntiCsrfProtection, - EnableLoginRateLimiting = EnableLoginRateLimiting, - - ModeConfigurations = ModeConfigurations, - OnConfigureEndpoints = OnConfigureEndpoints, - ConfigureServices = ConfigureServices, - CustomCookieManagerType = CustomCookieManagerType - }; - } + Mode = Mode, + HubDeploymentMode = HubDeploymentMode, + RoutePrefix = RoutePrefix, + + Session = Session.Clone(), + Tokens = Tokens.Clone(), + Pkce = Pkce.Clone(), + MultiTenant = MultiTenant.Clone(), + Cookie = Cookie.Clone(), + Diagnostics = Diagnostics.Clone(), + + PrimaryCredential = PrimaryCredential.Clone(), + AuthResponse = AuthResponse.Clone(), + Hub = Hub.Clone(), + SessionResolution = SessionResolution.Clone(), + UserIdentifiers = UserIdentifiers.Clone(), + + EnableLoginEndpoints = EnableLoginEndpoints, + EnablePkceEndpoints = EnablePkceEndpoints, + EnableTokenEndpoints = EnableTokenEndpoints, + EnableSessionEndpoints = EnableSessionEndpoints, + EnableUserInfoEndpoints = EnableUserInfoEndpoints, + EnableUserLifecycleEndpoints = EnableUserLifecycleEndpoints, + EnableUserProfileEndpoints = EnableUserProfileEndpoints, + EnableCredentialsEndpoints = EnableCredentialsEndpoints, + EnableAuthorizationEndpoints = EnableAuthorizationEndpoints, + + EnableAntiCsrfProtection = EnableAntiCsrfProtection, + EnableLoginRateLimiting = EnableLoginRateLimiting, + + ModeConfigurations = ModeConfigurations, + OnConfigureEndpoints = OnConfigureEndpoints, + ConfigureServices = ConfigureServices, + CustomCookieManagerType = CustomCookieManagerType + }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs index 1b77131c..1bc7c36f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs @@ -1,73 +1,42 @@ using CodeBeam.UltimateAuth.Core; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Options +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerOptionsValidator : IValidateOptions { - public sealed class UAuthServerOptionsValidator : IValidateOptions + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { - public ValidateOptionsResult Validate( - string? name, - UAuthServerOptions options) + if (string.IsNullOrWhiteSpace(options.RoutePrefix)) { - if (string.IsNullOrWhiteSpace(options.RoutePrefix)) - { - return ValidateOptionsResult.Fail( - "RoutePrefix must be specified."); - } + return ValidateOptionsResult.Fail( "RoutePrefix must be specified."); + } - if (options.RoutePrefix.Contains("//")) - { - return ValidateOptionsResult.Fail("RoutePrefix cannot contain '//'."); - } + if (options.RoutePrefix.Contains("//")) + { + return ValidateOptionsResult.Fail("RoutePrefix cannot contain '//'."); + } - // ------------------------- - // AUTH MODE VALIDATION - // ------------------------- - if (options.Mode.HasValue && !Enum.IsDefined(typeof(UAuthMode), options.Mode)) - { - return ValidateOptionsResult.Fail( - $"Invalid UAuthMode: {options.Mode}"); - } + if (options.Mode.HasValue && !Enum.IsDefined(typeof(UAuthMode), options.Mode)) + { + return ValidateOptionsResult.Fail($"Invalid UAuthMode: {options.Mode}"); + } - // ------------------------- - // SESSION VALIDATION - // ------------------------- - if (options.Mode != UAuthMode.PureJwt) + if (options.Mode != UAuthMode.PureJwt) + { + if (options.Session.Lifetime <= TimeSpan.Zero) { - 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."); - } + return ValidateOptionsResult.Fail("Session.Lifetime must be greater than zero."); } - // ------------------------- - // MULTI-TENANT VALIDATION - // ------------------------- - if (options.MultiTenant.Enabled) + if (options.Session.MaxLifetime is not null && + options.Session.MaxLifetime <= TimeSpan.Zero) { - if (options.MultiTenant.RequireTenant && - string.IsNullOrWhiteSpace(options.MultiTenant.DefaultTenantId)) - { - // This is allowed, but warn-worthy logic - // We still allow it, middleware will reject requests - } - - if (string.IsNullOrWhiteSpace(options.MultiTenant.TenantIdRegex)) - { - return ValidateOptionsResult.Fail( - "MultiTenant.TenantIdRegex must be specified."); - } + return ValidateOptionsResult.Fail( + "Session.MaxLifetime must be greater than zero when specified."); } - - return ValidateOptionsResult.Success; } + + return ValidateOptionsResult.Success; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs deleted file mode 100644 index 8eedf8a6..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerProfileDetector.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Server.Options -{ - internal sealed class UAuthServerProfileDetector : IServerProfileDetector - { - public UAuthClientProfile Detect(IServiceProvider sp) - { - 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; - } - - //if (sp.GetService() is not null) - // return UAuthClientProfile.WebServer; - - return UAuthClientProfile.NotSpecified; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs index 37881c5a..c7876148 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs @@ -1,37 +1,36 @@ -namespace CodeBeam.UltimateAuth.Server.Options +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 { - // 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; } = false; + public bool EnableBearer { get; set; } = true; + public bool EnableHeader { get; set; } = true; + public bool EnableCookie { get; set; } = true; + public bool EnableQuery { get; set; } = false; - public string HeaderName { get; set; } = "X-UAuth-Session"; - public string QueryParameterName { get; set; } = "session_id"; + 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" - }; + // 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) - }; + 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/UserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs index 0f63cc91..e7e14434 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs @@ -1,29 +1,27 @@ -namespace CodeBeam.UltimateAuth.Server.Options -{ - public sealed class UserIdentifierOptions - { - public bool AllowUsernameChange { get; set; } = true; - public bool AllowMultipleUsernames { get; set; } = false; - public bool AllowMultipleEmail { get; set; } = true; - public bool AllowMultiplePhone { get; set; } = true; +namespace CodeBeam.UltimateAuth.Server.Options; - public bool RequireEmailVerification { get; set; } = false; - public bool RequirePhoneVerification { get; set; } = false; +public sealed class UserIdentifierOptions +{ + 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 AllowAdminOverride { get; set; } = true; - public bool AllowUserOverride { get; set; } = true; + public bool RequireEmailVerification { get; set; } = false; + public bool RequirePhoneVerification { get; set; } = false; - internal UserIdentifierOptions Clone() => new() - { - AllowUsernameChange = AllowUsernameChange, - AllowMultipleUsernames = AllowMultipleUsernames, - AllowMultipleEmail = AllowMultipleEmail, - AllowMultiplePhone = AllowMultiplePhone, - RequireEmailVerification = RequireEmailVerification, - RequirePhoneVerification = RequirePhoneVerification, - AllowAdminOverride = AllowAdminOverride, - AllowUserOverride = AllowUserOverride - }; - } + public bool AllowAdminOverride { get; set; } = true; + public bool AllowUserOverride { get; set; } = true; + internal UserIdentifierOptions Clone() => new() + { + AllowUsernameChange = AllowUsernameChange, + AllowMultipleUsernames = AllowMultipleUsernames, + AllowMultipleEmail = AllowMultipleEmail, + AllowMultiplePhone = AllowMultiplePhone, + RequireEmailVerification = RequireEmailVerification, + RequirePhoneVerification = RequirePhoneVerification, + AllowAdminOverride = AllowAdminOverride, + AllowUserOverride = AllowUserOverride + }; } diff --git a/src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs b/src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs index a24437f0..1919ec0a 100644 --- a/src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs @@ -2,18 +2,17 @@ using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Runtime +namespace CodeBeam.UltimateAuth.Server.Runtime; + +public sealed class UAuthServerProductInfo { - public sealed class UAuthServerProductInfo - { - public string ProductName { get; init; } = "UltimateAuthServer"; - public UAuthProductInfo Core { get; init; } = default!; + public string ProductName { get; init; } = "UltimateAuthServer"; + public UAuthProductInfo Core { get; init; } = default!; - public UAuthMode? AuthMode { get; init; } - public UAuthHubDeploymentMode HubDeploymentMode { get; init; } + public UAuthMode? AuthMode { get; init; } + public UAuthHubDeploymentMode HubDeploymentMode { get; init; } - public bool PkceEnabled { get; init; } - public bool RefreshEnabled { get; init; } - public bool MultiTenancyEnabled { get; init; } - } + public bool PkceEnabled { get; init; } + public bool RefreshEnabled { get; init; } + public bool MultiTenancyEnabled { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs deleted file mode 100644 index db6542d6..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/DefaultRefreshFlowService.cs +++ /dev/null @@ -1,241 +0,0 @@ -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.Infrastructure; -using System.ComponentModel.DataAnnotations; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - internal sealed class DefaultRefreshFlowService : IRefreshFlowService - { - private readonly ISessionQueryService _sessionQueries; - private readonly ISessionTouchService _sessionRefresh; - private readonly IRefreshTokenRotationService _tokenRotation; - private readonly IRefreshTokenStore _refreshTokenStore; - private readonly IUserIdConverterResolver _userIdConverterResolver; - - public DefaultRefreshFlowService( - ISessionQueryService sessionQueries, - ISessionTouchService sessionRefresh, - IRefreshTokenRotationService tokenRotation, - IRefreshTokenStore refreshTokenStore, - IUserIdConverterResolver userIdConverterResolver) - { - _sessionQueries = sessionQueries; - _sessionRefresh = sessionRefresh; - _tokenRotation = tokenRotation; - _refreshTokenStore = refreshTokenStore; - _userIdConverterResolver = userIdConverterResolver; - } - - 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 validation = await _sessionQueries.ValidateSessionAsync( - new SessionValidationContext - { - TenantId = flow.TenantId, - SessionId = request.SessionId.Value, - Now = request.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, request.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 rotation = await _tokenRotation.RotateAsync( - flow, - new RefreshTokenRotationContext - { - RefreshToken = request.RefreshToken!, - Now = request.Now, - Device = request.Device - }, - ct); - - if (!rotation.Result.IsSuccess) - return RefreshFlowResult.ReauthRequired(); - - //if (rotation.Result.RefreshToken is not null) - //{ - // var converter = _userIdConverterResolver.GetConverter(); - - // await _refreshTokenStore.StoreAsync( - // flow.TenantId, - // new StoredRefreshToken - // { - // TokenHash = rotation.Result.RefreshToken.TokenHash, - // UserId = rotation.UserId!, - // SessionId = rotation.SessionId!.Value, - // ChainId = rotation.ChainId, - // ExpiresAt = rotation.Result.RefreshToken.ExpiresAt, - // IssuedAt = request.Now - // }, - // ct); - //} - - 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 validation = await _sessionQueries.ValidateSessionAsync( - new SessionValidationContext - { - TenantId = flow.TenantId, - SessionId = request.SessionId.Value, - Now = request.Now, - Device = request.Device - }, - ct); - - if (!validation.IsValid) - return RefreshFlowResult.ReauthRequired(); - - var rotation = await _tokenRotation.RotateAsync( - flow, - new RefreshTokenRotationContext - { - RefreshToken = request.RefreshToken!, - Now = request.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, request.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 validation = await _sessionQueries.ValidateSessionAsync( - new SessionValidationContext - { - TenantId = flow.TenantId, - SessionId = request.SessionId.Value, - Now = request.Now, - Device = request.Device - }, - ct); - - if (!validation.IsValid) - return RefreshFlowResult.ReauthRequired(); - - var rotation = await _tokenRotation.RotateAsync( - flow, - new RefreshTokenRotationContext - { - RefreshToken = request.RefreshToken!, - Now = request.Now, - Device = request.Device, - ExpectedSessionId = request.SessionId.Value - }, - ct); - - if (!rotation.Result.IsSuccess) - return RefreshFlowResult.ReauthRequired(); - - // ❗ NO SESSION TOUCH HERE - // Session lifetime is fixed in SemiHybrid - - //await StoreRefreshTokenAsync(flow, rotation, request.Now, ct); - - return RefreshFlowResult.Success( - outcome: RefreshOutcome.Rotated, - sessionId: request.SessionId.Value, - accessToken: rotation.Result.AccessToken, - refreshToken: rotation.Result.RefreshToken); - } - - //private async Task StoreRefreshTokenAsync(AuthFlowContext flow, RefreshTokenRotationExecution rotation, DateTimeOffset now, CancellationToken ct) - //{ - // if (rotation.Result.RefreshToken is null) - // return; - - // await _refreshTokenStore.StoreAsync( - // flow.TenantId, - // new StoredRefreshToken - // { - // TokenHash = rotation.Result.RefreshToken.TokenHash, - // UserId = rotation.UserId!, - // SessionId = rotation.SessionId!.Value, - // ChainId = rotation.ChainId, - // ExpiresAt = rotation.Result.RefreshToken.ExpiresAt, - // IssuedAt = now - // }, - // ct); - //} - - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs deleted file mode 100644 index 7e444b25..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; - -namespace CodeBeam.UltimateAuth.Server.Services -{ - internal sealed class DefaultSessionService : ISessionService - { - private readonly ISessionOrchestrator _orchestrator; - private readonly IClock _clock; - - public DefaultSessionService(ISessionOrchestrator orchestrator, IClock clock) - { - _orchestrator = orchestrator; - _clock = clock; - } - - public Task RevokeAllAsync(AuthContext authContext, UserKey userKey, CancellationToken ct) - { - return _orchestrator.ExecuteAsync(authContext, new RevokeAllUserSessionsCommand(userKey), ct); - } - - public Task RevokeAllExceptChainAsync(AuthContext authContext, UserKey userKey, SessionChainId exceptChainId, CancellationToken ct) - { - return _orchestrator.ExecuteAsync(authContext, new RevokeAllChainsCommand(userKey, exceptChainId), ct); - } - - public Task RevokeRootAsync(AuthContext authContext, UserKey userKey, CancellationToken ct) - { - return _orchestrator.ExecuteAsync(authContext, new RevokeRootCommand(userKey), ct); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs index 663bb9ba..0b13193c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Server.Services +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface IRefreshFlowService { - public interface IRefreshFlowService - { - Task RefreshAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct = default); - } + Task RefreshAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs index 6bb844b2..16b55080 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Server.Services +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface IRefreshTokenRotationService { - public interface IRefreshTokenRotationService - { - Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default); - } + Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs index fa674213..fd6e472d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs @@ -1,18 +1,31 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Services -{ - public interface ISessionQueryService - { - Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default); +// TenantId parameter only come from AuthFlowContext. +namespace CodeBeam.UltimateAuth.Server.Services; - Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); +/// +/// 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); - Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default); + /// + /// Retrieves all sessions belonging to a specific chain. + /// + Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default); - Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + /// + /// Retrieves all session chains for a user. + /// + Task> GetChainsByUserAsync(UserKey userKey, CancellationToken ct = default); - Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, 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/ISessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionValidator.cs new file mode 100644 index 00000000..665455ee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/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/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs index a0f00e8d..036c5481 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs @@ -1,28 +1,27 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Server.Services +namespace CodeBeam.UltimateAuth.Server.Services; + +/// +/// Handles authentication flows such as login, +/// logout, session refresh and reauthentication. +/// +public interface IUAuthFlowService { - /// - /// 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 flow, LoginRequest request, CancellationToken ct = default); - Task LoginAsync(AuthFlowContext auth, AuthExecutionContext execution, LoginRequest request, CancellationToken ct); + Task LoginAsync(AuthFlowContext auth, AuthExecutionContext execution, LoginRequest request, CancellationToken ct); - Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); + Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); - Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default); + Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default); - Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default); + Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default); - Task LogoutAsync(LogoutRequest request, CancellationToken ct = default); + Task LogoutAsync(LogoutRequest request, CancellationToken ct = default); - Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default); + Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default); - Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); - } + Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs new file mode 100644 index 00000000..e3db3a89 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs @@ -0,0 +1,207 @@ +using CodeBeam.UltimateAuth.Core; +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; + + public RefreshFlowService( + ISessionValidator sessionValidator, + ISessionTouchService sessionRefresh, + IRefreshTokenRotationService tokenRotation) + { + _sessionValidator = sessionValidator; + _sessionRefresh = sessionRefresh; + _tokenRotation = tokenRotation; + } + + 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 validation = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = flow.Tenant, + SessionId = request.SessionId.Value, + Now = request.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, request.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 rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + //if (rotation.Result.RefreshToken is not null) + //{ + // var converter = _userIdConverterResolver.GetConverter(); + + // await _refreshTokenStore.StoreAsync( + // flow.TenantId, + // new StoredRefreshToken + // { + // TokenHash = rotation.Result.RefreshToken.TokenHash, + // UserId = rotation.UserId!, + // SessionId = rotation.SessionId!.Value, + // ChainId = rotation.ChainId, + // ExpiresAt = rotation.Result.RefreshToken.ExpiresAt, + // IssuedAt = request.Now + // }, + // ct); + //} + + 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 validation = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = flow.Tenant, + SessionId = request.SessionId.Value, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.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, request.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 validation = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = flow.Tenant, + SessionId = request.SessionId.Value, + Now = request.Now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = request.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 index e61298fe..73222294 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,9 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; -using System; namespace CodeBeam.UltimateAuth.Server.Services; @@ -28,7 +28,7 @@ public async Task RotateAsync(AuthFlowContext flo var validation = await _validator.ValidateAsync( new RefreshTokenValidationContext { - TenantId = flow.TenantId, + Tenant = flow.Tenant, RefreshToken = context.RefreshToken, Now = context.Now, Device = context.Device, @@ -43,11 +43,11 @@ public async Task RotateAsync(AuthFlowContext flo { if (validation.ChainId is not null) { - await _store.RevokeByChainAsync(validation.TenantId, validation.ChainId.Value, context.Now, ct); + await _store.RevokeByChainAsync(validation.Tenant, validation.ChainId.Value, context.Now, ct); } else if (validation.SessionId is not null) { - await _store.RevokeBySessionAsync(validation.TenantId, validation.SessionId.Value, context.Now, ct); + await _store.RevokeBySessionAsync(validation.Tenant, validation.SessionId.Value, context.Now, ct); } return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; @@ -58,11 +58,21 @@ public async Task RotateAsync(AuthFlowContext flo throw new InvalidOperationException("Validated refresh token does not contain a UserKey."); } + if (validation.SessionId is not AuthSessionId sessionId) + { + throw new InvalidOperationException("Validated refresh token does not contain a SessionId."); + } + + if (validation.TokenHash == null) + { + throw new InvalidOperationException("Validated refresh token does not contain a hashed token."); + } + var tokenContext = new TokenIssuanceContext { - TenantId = flow.OriginalOptions.MultiTenant.Enabled - ? validation.TenantId - : null, + Tenant = flow.OriginalOptions.MultiTenant.Enabled + ? validation.Tenant + : TenantKey.Single, UserKey = uKey, SessionId = validation.SessionId, @@ -80,23 +90,23 @@ public async Task RotateAsync(AuthFlowContext flo // Never issue new refresh token before revoke old. Upperline doesn't persist token currently. // TODO: Add _store.ExecuteAsync here to wrap RevokeAsync and StoreAsync - await _store.RevokeAsync(validation.TenantId, validation.TokenHash, context.Now, refreshToken.TokenHash, ct); + await _store.RevokeAsync(validation.Tenant, validation.TokenHash, context.Now, refreshToken.TokenHash, ct); var stored = new StoredRefreshToken { - TenantId = flow.TenantId, + Tenant = flow.Tenant, TokenHash = refreshToken.TokenHash, UserKey = uKey, - SessionId = validation.SessionId.Value, + SessionId = sessionId, ChainId = validation.ChainId, IssuedAt = _clock.UtcNow, ExpiresAt = refreshToken.ExpiresAt }; - await _store.StoreAsync(validation.TenantId, stored); + await _store.StoreAsync(validation.Tenant, stored); return new RefreshTokenRotationExecution() { - TenantId = validation.TenantId, + Tenant = validation.Tenant, UserKey = validation.UserKey, SessionId = validation.SessionId, ChainId = validation.ChainId, diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 6864311e..18202efc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -1,112 +1,94 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +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; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Login; -using System; +using CodeBeam.UltimateAuth.Server.Flows; -namespace CodeBeam.UltimateAuth.Server.Services +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class UAuthFlowService : IUAuthFlowService { - internal sealed class UAuthFlowService : IUAuthFlowService + private readonly IAuthFlowContextAccessor _authFlow; + private readonly ILoginOrchestrator _loginOrchestrator; + private readonly ISessionOrchestrator _orchestrator; + + public UAuthFlowService( + IAuthFlowContextAccessor authFlow, + ILoginOrchestrator loginOrchestrator, + ISessionOrchestrator orchestrator) { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly ILoginOrchestrator _loginOrchestrator; - private readonly ISessionOrchestrator _orchestrator; - private readonly ISessionQueryService _queries; - private readonly ITokenIssuer _tokens; - private readonly IUserIdConverterResolver _userIdConverterResolver; - private readonly IRefreshTokenValidator _tokenValidator; - - public UAuthFlowService( - IAuthFlowContextAccessor authFlow, - ILoginOrchestrator loginOrchestrator, - ISessionOrchestrator orchestrator, - ISessionQueryService queries, - ITokenIssuer tokens, - IUserIdConverterResolver userIdConverterResolver, - IRefreshTokenValidator tokenValidator) - { - _authFlow = authFlow; - _loginOrchestrator = loginOrchestrator; - _orchestrator = orchestrator; - _queries = queries; - _tokens = tokens; - _userIdConverterResolver = userIdConverterResolver; - _tokenValidator = tokenValidator; - } - - public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } + _authFlow = authFlow; + _loginOrchestrator = loginOrchestrator; + _orchestrator = orchestrator; + } - public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } + public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } - public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } + public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } - public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) - { - return _loginOrchestrator.LoginAsync(flow, request, ct); - } + public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } - public Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) - { - var effectiveFlow = execution.EffectiveClientProfile is null - ? flow - : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); - return _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); - } + public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + { + return _loginOrchestrator.LoginAsync(flow, request, ct); + } - public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) - { - var authFlow = _authFlow.Current; - var now = request.At ?? DateTimeOffset.UtcNow; - var authContext = authFlow.ToAuthContext(now); + public Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) + { + var effectiveFlow = execution.EffectiveClientProfile is null + ? flow + : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); + return _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); + } - return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.TenantId, request.SessionId), ct); - } + public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + { + var authFlow = _authFlow.Current; + var now = request.At ?? DateTimeOffset.UtcNow; + var authContext = authFlow.ToAuthContext(now); - public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) - { - var authFlow = _authFlow.Current; - var now = request.At ?? DateTimeOffset.UtcNow; + return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.SessionId), ct); + } - if (authFlow.Session is not SessionSecurityContext session) - throw new InvalidOperationException("LogoutAll requires an active session."); + public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) + { + var authFlow = _authFlow.Current; + var now = request.At ?? DateTimeOffset.UtcNow; - var authContext = authFlow.ToAuthContext(now); - SessionChainId? exceptChainId = null; + if (authFlow.Session is not SessionSecurityContext session) + throw new InvalidOperationException("LogoutAll requires an active session."); - if (request.ExceptCurrent) - { - exceptChainId = session.ChainId; + var authContext = authFlow.ToAuthContext(now); + SessionChainId? exceptChainId = null; - if (exceptChainId is null) - throw new InvalidOperationException("Current session chain could not be resolved."); - } + if (request.ExceptCurrent) + { + exceptChainId = session.ChainId; - if (authFlow.UserKey is UserKey uaKey) - { - var command = new RevokeAllChainsCommand(uaKey, exceptChainId); - await _orchestrator.ExecuteAsync(authContext, command, ct); - } - + if (exceptChainId is null) + throw new InvalidOperationException("Current session chain could not be resolved."); } - public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) + if (authFlow.UserKey is UserKey uaKey) { - throw new NotImplementedException(); + var command = new RevokeAllChainsCommand(uaKey, exceptChainId); + await _orchestrator.ExecuteAsync(authContext, command, ct); } + } + public Task ReauthenticateAsync(ReauthRequest 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 index 931fd7dd..ab4b6d4b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs @@ -4,82 +4,80 @@ using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using System.Security.Claims; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Server.Services +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class UAuthJwtValidator : IJwtValidator { - internal sealed class UAuthJwtValidator : IJwtValidator + private readonly JsonWebTokenHandler _jwtHandler; + private readonly TokenValidationParameters _jwtParameters; + private readonly IUserIdConverterResolver _converters; + + public UAuthJwtValidator(TokenValidationParameters jwtParameters, IUserIdConverterResolver converters) { - private readonly JsonWebTokenHandler _jwtHandler; - private readonly TokenValidationParameters _jwtParameters; - private readonly IUserIdConverterResolver _converters; + _jwtHandler = new JsonWebTokenHandler(); + _jwtParameters = jwtParameters; + _converters = 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); - public async Task> ValidateAsync(string token, CancellationToken ct = default) + if (!result.IsValid) { - 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(); + return TokenValidationResult.Invalid(TokenType.Jwt, MapJwtError(result.Exception)); + } - var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value; - if (string.IsNullOrWhiteSpace(userIdString)) - { - return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.MissingSubject); - } + var jwt = (JsonWebToken)result.SecurityToken; + var claims = jwt.Claims.ToArray(); - TUserId userId; - try - { - userId = converter.FromString(userIdString); - } - catch - { - return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.Malformed); - } + var converter = _converters.GetConverter(); - 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; - } + var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value; + if (string.IsNullOrWhiteSpace(userIdString)) + { + return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.MissingSubject); + } - return TokenValidationResult.Valid( - type: TokenType.Jwt, - tenantId: tenantId, - userId, - sessionId: sessionId, - claims: claims, - expiresAt: jwt.ValidTo); + TUserId userId; + try + { + userId = converter.FromString(userIdString); + } + catch + { + return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.Malformed); } - private static TokenInvalidReason MapJwtError(Exception? ex) + 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)) { - return ex switch - { - SecurityTokenExpiredException => TokenInvalidReason.Expired, - SecurityTokenInvalidSignatureException => TokenInvalidReason.SignatureInvalid, - SecurityTokenInvalidAudienceException => TokenInvalidReason.AudienceMismatch, - SecurityTokenInvalidIssuerException => TokenInvalidReason.IssuerMismatch, - _ => TokenInvalidReason.Invalid - }; + 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/UAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs index 3e05168f..7cd1b428 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs @@ -1,90 +1,50 @@ 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 CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; // TODO: Add wrapper service in client project. Validate method also may add. -namespace CodeBeam.UltimateAuth.Server.Services -{ - internal sealed class UAuthSessionManager : IUAuthSessionManager - { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly ISessionOrchestrator _orchestrator; - private readonly ISessionQueryService _sessionQueryService; - private readonly IClock _clock; - - public UAuthSessionManager(IAuthFlowContextAccessor authFlow, ISessionOrchestrator orchestrator, ISessionQueryService sessionQueryService, IClock clock) - { - _authFlow = authFlow; - _orchestrator = orchestrator; - _sessionQueryService = sessionQueryService; - _clock = clock; - } - - public Task> GetChainsAsync( - string? tenantId, - UserKey userKey) - => _sessionQueryService.GetChainsByUserAsync(tenantId, userKey); - - public Task> GetSessionsAsync( - string? tenantId, - SessionChainId chainId) - => _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId); - - public Task GetSessionAsync( - string? tenantId, - AuthSessionId sessionId) - => _sessionQueryService.GetSessionAsync(tenantId, sessionId); - - public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeSessionCommand(tenantId, sessionId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId) - => _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); - - public Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeAllChainsCommand(userKey, exceptChainId); - - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeChainCommand(chainId); +namespace CodeBeam.UltimateAuth.Server.Services; - return _orchestrator.ExecuteAsync(authContext, command); - } - - public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeRootCommand(userKey); - - return _orchestrator.ExecuteAsync(authContext, command); - } +internal sealed class UAuthSessionManager : IUAuthSessionManager +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly ISessionOrchestrator _orchestrator; + private readonly IClock _clock; - public async Task GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) - { - var chainId = await _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); + public UAuthSessionManager(IAuthFlowContextAccessor authFlow, ISessionOrchestrator orchestrator, IClock clock) + { + _authFlow = authFlow; + _orchestrator = orchestrator; + _clock = clock; + } - if (chainId is null) - return null; + public Task RevokeSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeSessionCommand(sessionId); + return _orchestrator.ExecuteAsync(authContext, command, ct); + } - var sessions = await _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId.Value); + public Task RevokeChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeChainCommand(chainId); + return _orchestrator.ExecuteAsync(authContext, command, ct); + } - return sessions.FirstOrDefault(s => s.SessionId == sessionId); - } + public Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeAllChainsCommand(userKey, exceptChainId); + return _orchestrator.ExecuteAsync(authContext, command, ct); + } + public Task RevokeRootAsync(UserKey userKey, CancellationToken ct = default) + { + var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); + var command = new RevokeRootCommand(userKey); + return _orchestrator.ExecuteAsync(authContext, command, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs index a6736a01..04d34fe4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs @@ -1,82 +1,46 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; -using System.ComponentModel.DataAnnotations; +using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Server.Services -{ - public sealed class UAuthSessionQueryService : ISessionQueryService - { - private readonly ISessionStoreKernelFactory _storeFactory; - private readonly IUserClaimsProvider _claimsProvider; - private readonly UAuthServerOptions _options; - - public UAuthSessionQueryService(ISessionStoreKernelFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) - { - _storeFactory = storeFactory; - _claimsProvider = claimsProvider; - _options = options.Value; - } - - public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) - { - var kernel = _storeFactory.Create(context.TenantId); - - var session = await kernel.GetSessionAsync(context.SessionId); - if (session is null) - return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); +namespace CodeBeam.UltimateAuth.Server.Services; - var state = session.GetState(context.Now, _options.Session.IdleTimeout); - if (state != SessionState.Active) - return SessionValidationResult.Invalid(state, sessionId: session.SessionId, chainId: 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 root = await kernel.GetSessionRootByUserAsync(session.UserKey); - if (root is null || root.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId, root?.RootId); - - if (session.SecurityVersionAtCreation != root.SecurityVersion) - return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId); +public sealed class UAuthSessionQueryService : ISessionQueryService +{ + private readonly ISessionStoreKernelFactory _storeFactory; + private readonly IAuthFlowContextAccessor _authFlow; - // TODO: Implement device id, AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. - // Currently this line has error on refresh flow. - //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) - // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); + public UAuthSessionQueryService( + ISessionStoreKernelFactory storeFactory, + IAuthFlowContextAccessor authFlow) + { + _storeFactory = storeFactory; + _authFlow = authFlow; + } - var claims = await _claimsProvider.GetClaimsAsync(context.TenantId, session.UserKey, ct); - return SessionValidationResult.Active(context.TenantId, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, boundDeviceId: session.Device.DeviceId); - } + public Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + return CreateKernel().GetSessionAsync(sessionId); + } - public Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) - { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetSessionAsync(sessionId); - } + public Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + return CreateKernel().GetSessionsByChainAsync(chainId); + } - public Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) - { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetSessionsByChainAsync(chainId); - } + public Task> GetChainsByUserAsync(UserKey userKey, CancellationToken ct = default) + { + return CreateKernel().GetChainsByUserAsync(userKey); + } - public Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetChainsByUserAsync(userKey); - } + public Task ResolveChainIdAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + return CreateKernel().GetChainIdBySessionAsync(sessionId); + } - public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) - { - var kernel = _storeFactory.Create(tenantId); - return kernel.GetChainIdBySessionAsync(sessionId); - } + private ISessionStoreKernel 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..f9ab8aa0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -0,0 +1,58 @@ +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 ISessionStoreKernelFactory _storeFactory; + private readonly IUserClaimsProvider _claimsProvider; + private readonly UAuthServerOptions _options; + + public UAuthSessionValidator( + ISessionStoreKernelFactory storeFactory, + IUserClaimsProvider claimsProvider, + IOptions options) + { + _storeFactory = storeFactory; + _claimsProvider = claimsProvider; + _options = options.Value; + } + + // Validate runs before AuthFlowContext is set, do not call _authFlow here. + 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, _options.Session.IdleTimeout); + if (state != SessionState.Active) + return SessionValidationResult.Invalid(state, sessionId: session.SessionId, chainId: 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 root = await kernel.GetSessionRootByUserAsync(session.UserKey); + if (root is null || root.IsRevoked) + return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId, root?.RootId); + + if (session.SecurityVersionAtCreation != root.SecurityVersion) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId); + + // TODO: Implement device id, AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. + // Currently this line has error on refresh flow. + //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) + // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); + + var claims = await _claimsProvider.GetClaimsAsync(context.Tenant, session.UserKey, ct); + return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, boundDeviceId: session.Device.DeviceId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs b/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs index ac2fc6f0..51b261f7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs +++ b/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs @@ -1,38 +1,32 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Identity; +namespace CodeBeam.UltimateAuth.Server.Stores; -namespace CodeBeam.UltimateAuth.Server.Stores +public sealed class AspNetIdentityUserStore // : IUAuthUserStore { - public sealed class AspNetIdentityUserStore // : IUAuthUserStore - { - //private readonly UserManager _users; + //private readonly UserManager _users; - //public AspNetIdentityUserStore(UserManager users) - //{ - // _users = users; - //} + //public AspNetIdentityUserStore(UserManager users) + //{ + // _users = users; + //} - //public async Task?> FindByUsernameAsync( - // string? tenantId, - // string username, - // CancellationToken cancellationToken = default) - //{ - // var user = await _users.FindByNameAsync(username); - // if (user is null) - // return null; + //public async Task?> FindByUsernameAsync( + // string? tenantId, + // string username, + // CancellationToken cancellationToken = default) + //{ + // var user = await _users.FindByNameAsync(username); + // if (user is null) + // return null; - // var claims = await _users.GetClaimsAsync(user); - - // return new UAuthUserRecord - // { - // UserId = user.Id, - // Username = user.UserName!, - // PasswordHash = user.PasswordHash!, - // Claims = ClaimsSnapshot.From( - // claims.Select(c => (c.Type, c.Value)).ToArray()) - // }; - //} - } + // var claims = await _users.GetClaimsAsync(user); + // return new UAuthUserRecord + // { + // UserId = user.Id, + // Username = user.UserName!, + // PasswordHash = user.PasswordHash!, + // Claims = ClaimsSnapshot.From( + // claims.Select(c => (c.Type, c.Value)).ToArray()) + // }; + //} } diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs deleted file mode 100644 index d1e541d7..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Stores/UAuthSessionStoreFactory.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Server.Stores -{ - /// - /// UltimateAuth default session store factory. - /// Resolves session store kernels from DI and provides them - /// to framework-level session stores. - /// - public sealed class UAuthSessionStoreFactory : ISessionStoreKernelFactory - { - private readonly IServiceProvider _provider; - - public UAuthSessionStoreFactory(IServiceProvider provider) - { - _provider = provider; - } - - public ISessionStoreKernel Create(string? tenantId) - { - var kernel = _provider.GetService(); - - if (kernel is ITenantAwareSessionStore tenantAware) - { - tenantAware.BindTenant(tenantId); - } - - return kernel; - } - - } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs index a4bf9285..6afebc1d 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class AssignRoleRequest { - public sealed class AssignRoleRequest - { - public required string Role { get; init; } - } + public required string Role { get; init; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs index 078a504f..4a1958d1 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class AuthorizationCheckRequest { - public sealed class AuthorizationCheckRequest - { - public required string Action { get; init; } - public string? Resource { get; init; } - public string? ResourceId { get; init; } - } + 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/Responses/UserRolesResponse.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs index 17afba35..d345e84e 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs @@ -1,11 +1,9 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Authorization.Contracts -{ - public sealed record UserRolesResponse - { - public required UserKey UserKey { get; init; } - public required IReadOnlyCollection Roles { get; init; } - } +namespace CodeBeam.UltimateAuth.Authorization.Contracts; +public sealed record UserRolesResponse +{ + public required UserKey UserKey { get; init; } + public required IReadOnlyCollection Roles { get; init; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs deleted file mode 100644 index 2e4bd92c..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace CodeBeam.UltimateAuth.Authorization.InMemory.Extensions -{ - public static class AuthorizationInMemoryExtensions - { - public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) - { - services.TryAddSingleton(); - - // Never try add - seeding is enumerated and all contributors are added. - services.AddSingleton(); - - return services; - } - } -} 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..d4698be1 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +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(); + + // Never try add - seeding is enumerated and all contributors are added. + services.AddSingleton(); + + return services; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs index e7d05618..750a4d66 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Authorization.InMemory +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +public interface IAuthorizationSeeder { - public interface IAuthorizationSeeder - { - Task SeedAsync(CancellationToken ct = default); - } + Task SeedAsync(CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs index cd459d7d..da4efad1 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.InMemory; @@ -17,10 +18,9 @@ public InMemoryAuthorizationSeedContributor(IUserRoleStore roles, IInMemoryUserI _ids = ids; } - public async Task SeedAsync(string? tenantId, CancellationToken ct = default) + public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { var adminKey = _ids.GetAdminUserId(); - - await _roles.AssignAsync(tenantId, adminKey, "Admin", ct); + await _roles.AssignAsync(tenant, adminKey, "Admin", ct); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs index 3f54673f..4616d46c 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -1,17 +1,18 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Authorization.InMemory; internal sealed class InMemoryUserRoleStore : IUserRoleStore { - private readonly ConcurrentDictionary<(string? TenantId, UserKey UserKey), HashSet> _roles = new(); + private readonly ConcurrentDictionary<(TenantKey Tenant, UserKey UserKey), HashSet> _roles = new(); - public Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_roles.TryGetValue((tenantId, userKey), out var set)) + if (_roles.TryGetValue((tenant, userKey), out var set)) { lock (set) { @@ -22,11 +23,11 @@ public Task> GetRolesAsync(string? tenantId, UserKey return Task.FromResult>(Array.Empty()); } - public Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + public Task AssignAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var set = _roles.GetOrAdd((tenantId, userKey), _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + var set = _roles.GetOrAdd((tenant, userKey), _ => new HashSet(StringComparer.OrdinalIgnoreCase)); lock (set) { set.Add(role); @@ -35,11 +36,11 @@ public Task AssignAsync(string? tenantId, UserKey userKey, string role, Cancella return Task.CompletedTask; } - public Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + public Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_roles.TryGetValue((tenantId, userKey), out var set)) + if (_roles.TryGetValue((tenant, userKey), out var set)) { lock (set) { diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs index 4f7474a1..52d62db5 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs @@ -1,22 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Authorization.Reference -{ - internal sealed class AssignUserRoleCommand : IAccessCommand - { - private readonly Func _execute; - private readonly IEnumerable _policies; - - public AssignUserRoleCommand(IEnumerable policies, Func execute) - { - _policies = policies; - _execute = execute; - } +namespace CodeBeam.UltimateAuth.Authorization.Reference; - public IEnumerable GetPolicies(AccessContext context) => _policies; +internal sealed class AssignUserRoleCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public AssignUserRoleCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs index 58c51d2e..a57a1b16 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs @@ -1,22 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Authorization.Reference -{ - internal sealed class GetUserRolesCommand : IAccessCommand> - { - private readonly IEnumerable _policies; - private readonly Func>> _execute; - - public GetUserRolesCommand(IEnumerable policies, Func>> execute) - { - _policies = policies; - _execute = execute; - } +namespace CodeBeam.UltimateAuth.Authorization.Reference; - public IEnumerable GetPolicies(AccessContext context) => _policies; +internal sealed class GetUserRolesCommand : IAccessCommand> +{ + private readonly Func>> _execute; - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public GetUserRolesCommand(Func>> execute) + { + _execute = execute; } + + public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs index d380b76d..35693190 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs @@ -1,22 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Authorization.Reference -{ - internal sealed class RemoveUserRoleCommand : IAccessCommand - { - private readonly Func _execute; - private readonly IEnumerable _policies; - - public RemoveUserRoleCommand(IEnumerable policies, Func execute) - { - _policies = policies; - _execute = execute; - } +namespace CodeBeam.UltimateAuth.Authorization.Reference; - public IEnumerable GetPolicies(AccessContext context) => _policies; +internal sealed class RemoveUserRoleCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public RemoveUserRoleCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } 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..a86fb67b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -0,0 +1,137 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +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 _roles; + private readonly IAccessContextFactory _accessContextFactory; + + public AuthorizationEndpointHandler(IAuthFlowContextAccessor authFlow, IAuthorizationService authorization, IUserRoleService roles, IAccessContextFactory accessContextFactory) + { + _authFlow = authFlow; + _authorization = authorization; + _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 accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.ReadSelf, + resource: "authorization.roles", + resourceId: flow.UserKey!.Value + ); + + var roles = await _roles.GetRolesAsync(accessContext, flow.UserKey!.Value, 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 accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.ReadAdmin, + resource: "authorization.roles", + resourceId: userKey.Value + ); + + var roles = await _roles.GetRolesAsync(accessContext, userKey, 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 _roles.AssignAsync(accessContext, userKey, req.Role, 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 _roles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + return Results.Ok(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs deleted file mode 100644 index 99f8258b..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs +++ /dev/null @@ -1,132 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; -using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Server.Extensions; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Authorization.Reference -{ - public sealed class DefaultAuthorizationEndpointHandler : IAuthorizationEndpointHandler - { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAuthorizationService _authorization; - private readonly IUserRoleService _roles; - private readonly IAccessContextFactory _accessContextFactory; - - public DefaultAuthorizationEndpointHandler(IAuthFlowContextAccessor authFlow, IAuthorizationService authorization, IUserRoleService roles, IAccessContextFactory accessContextFactory) - { - _authFlow = authFlow; - _authorization = authorization; - _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); - - 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 accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Authorization.Roles.ReadSelf, - resource: "authorization.roles", - resourceId: flow.UserKey!.Value - ); - - var roles = await _roles.GetRolesAsync(accessContext, flow.UserKey!.Value, 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 accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Authorization.Roles.ReadAdmin, - resource: "authorization.roles", - resourceId: userKey.Value - ); - - var roles = await _roles.GetRolesAsync(accessContext, userKey, 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 _roles.AssignAsync(accessContext, userKey, req.Role, 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 _roles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); - return Results.Ok(); - } - } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs deleted file mode 100644 index 8e49f8ca..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Endpoints; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace CodeBeam.UltimateAuth.Authorization.Reference.Extensions -{ - public static class AuthorizationReferenceExtensions - { - public static IServiceCollection AddUltimateAuthAuthorizationReference(this IServiceCollection services) - { - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - - return services; - } - } - -} 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..3142d271 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +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(); + + return services; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs deleted file mode 100644 index 2d2f3bd9..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; - -namespace CodeBeam.UltimateAuth.Authorization.Reference -{ - public sealed class DefaultRolePermissionResolver : IRolePermissionResolver - { - private static readonly IReadOnlyDictionary _map - = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["admin"] = new[] - { - new Permission("*") - }, - ["user"] = new[] - { - new Permission("profile.read"), - new Permission("profile.update") - } - }; - - public Task> ResolveAsync(string? tenantId, IEnumerable roles, CancellationToken ct = default) - { - var result = new List(); - - foreach (var role in roles) - { - if (_map.TryGetValue(role, out var perms)) - result.AddRange(perms); - } - - return Task.FromResult>(result); - } - } - -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs deleted file mode 100644 index a7aae0fa..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Authorization.Reference -{ - public sealed class DefaultUserPermissionStore : IUserPermissionStore - { - private readonly IUserRoleStore _roles; - private readonly IRolePermissionResolver _resolver; - - public DefaultUserPermissionStore(IUserRoleStore roles, IRolePermissionResolver resolver) - { - _roles = roles; - _resolver = resolver; - } - - public async Task> GetPermissionsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - var roles = await _roles.GetRolesAsync(tenantId, userKey, ct); - return await _resolver.ResolveAsync(tenantId, roles, ct); - } - } - -} 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..4f5d5a7b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +public sealed class RolePermissionResolver : IRolePermissionResolver +{ + private static readonly IReadOnlyDictionary _map + = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["admin"] = new[] + { + new Permission("*") + }, + ["user"] = new[] + { + new Permission("profile.read"), + new Permission("profile.update") + } + }; + + public Task> ResolveAsync(TenantKey tenant, IEnumerable roles, CancellationToken ct = default) + { + var result = new List(); + + foreach (var role in roles) + { + if (_map.TryGetValue(role, out var perms)) + result.AddRange(perms); + } + + return Task.FromResult>(result); + } +} 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..150eae91 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +public sealed class UserPermissionStore : IUserPermissionStore +{ + private readonly IUserRoleStore _roles; + private readonly IRolePermissionResolver _resolver; + + public UserPermissionStore(IUserRoleStore roles, IRolePermissionResolver resolver) + { + _roles = roles; + _resolver = resolver; + } + + public async Task> GetPermissionsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var roles = await _roles.GetRolesAsync(tenant, userKey, ct); + return await _resolver.ResolveAsync(tenant, roles, ct); + } +} 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..3b01d601 --- /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.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Abstractions; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class AuthorizationService : IAuthorizationService +{ + private readonly IAccessPolicyProvider _policyProvider; + private readonly IAccessAuthority _accessAuthority; + + public AuthorizationService(IAccessPolicyProvider policyProvider, IAccessAuthority accessAuthority) + { + _policyProvider = policyProvider; + _accessAuthority = accessAuthority; + } + + public Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var policies = _policyProvider.GetPolicies(context); + var decision = _accessAuthority.Decide(context, policies); + + if (decision.RequiresReauthentication) + return Task.FromResult(AuthorizationResult.ReauthRequired()); + + return Task.FromResult( + decision.IsAllowed + ? AuthorizationResult.Allow() + : AuthorizationResult.Deny(decision.DenyReason) + ); + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs deleted file mode 100644 index 78acfa89..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs +++ /dev/null @@ -1,37 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Policies.Abstractions; - -namespace CodeBeam.UltimateAuth.Authorization.Reference -{ - internal sealed class DefaultAuthorizationService : IAuthorizationService - { - private readonly IAccessPolicyProvider _policyProvider; - private readonly IAccessAuthority _accessAuthority; - - public DefaultAuthorizationService(IAccessPolicyProvider policyProvider, IAccessAuthority accessAuthority) - { - _policyProvider = policyProvider; - _accessAuthority = accessAuthority; - } - - public Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = _policyProvider.GetPolicies(context); - var decision = _accessAuthority.Decide(context, policies); - - if (decision.RequiresReauthentication) - return Task.FromResult(AuthorizationResult.ReauthRequired()); - - return Task.FromResult( - decision.IsAllowed - ? AuthorizationResult.Allow() - : AuthorizationResult.Deny(decision.DenyReason) - ); - } - - } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultUserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultUserRoleService.cs deleted file mode 100644 index 08129ed4..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultUserRoleService.cs +++ /dev/null @@ -1,62 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference -{ - internal sealed class DefaultUserRoleService : IUserRoleService - { - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserRoleStore _store; - - public DefaultUserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore store) - { - _accessOrchestrator = accessOrchestrator; - _store = store; - } - - public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(role)) - throw new ArgumentException("role_empty", nameof(role)); - - var cmd = new AssignUserRoleCommand(Array.Empty(), - async innerCt => - { - await _store.AssignAsync(context.ResourceTenantId, targetUserKey, role, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(role)) - throw new ArgumentException("role_empty", nameof(role)); - - var cmd = new RemoveUserRoleCommand(Array.Empty(), - async innerCt => - { - await _store.RemoveAsync(context.ResourceTenantId, targetUserKey, role, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - - public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new GetUserRolesCommand(Array.Empty(), - innerCt => _store.GetRolesAsync(context.ResourceTenantId, targetUserKey, innerCt)); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs index a3de9c7f..35d09545 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs @@ -1,11 +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); - } +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/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs new file mode 100644 index 00000000..1def14bf --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -0,0 +1,59 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class UserRoleService : IUserRoleService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserRoleStore _store; + + public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore store) + { + _accessOrchestrator = accessOrchestrator; + _store = store; + } + + public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(role)) + throw new ArgumentException("role_empty", nameof(role)); + + var cmd = new AssignUserRoleCommand( + async innerCt => + { + await _store.AssignAsync(context.ResourceTenant, targetUserKey, role, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(role)) + throw new ArgumentException("role_empty", nameof(role)); + + var cmd = new RemoveUserRoleCommand( + async innerCt => + { + await _store.RemoveAsync(context.ResourceTenant, targetUserKey, role, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + + public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new GetUserRolesCommand(innerCt => _store.GetRolesAsync(context.ResourceTenant, targetUserKey, innerCt)); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs index 7042d45b..d6e09167 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs @@ -1,9 +1,9 @@ using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Authorization +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRolePermissionResolver { - public interface IRolePermissionResolver - { - Task> ResolveAsync(string? tenantId, IEnumerable roles, CancellationToken ct = default); - } + Task> ResolveAsync(TenantKey tenant, IEnumerable roles, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs index f093d400..db519dc2 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs @@ -1,9 +1,10 @@ using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IUserPermissionStore { - Task> GetPermissionsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + 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 index b335d236..3c9a4f29 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs @@ -1,12 +1,11 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Authorization +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IUserRoleService { - public interface IUserRoleService - { - Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); - Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); - Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); - } + Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); + Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); + Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs index e2aae185..028f5f6a 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Authorization +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IUserRoleStore { - public interface IUserRoleStore - { - Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default); - Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default); - Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); - } + Task AssignAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default); + Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default); + Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs new file mode 100644 index 00000000..99fbd3a5 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core; +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 IUserRoleStore _roles; + private readonly IUserPermissionStore _permissions; + + public AuthorizationClaimsProvider(IUserRoleStore roles, IUserPermissionStore permissions) + { + _roles = roles; + _permissions = permissions; + } + + public async Task GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var roles = await _roles.GetRolesAsync(tenant, userKey, ct); + var perms = await _permissions.GetPermissionsAsync(tenant, userKey, ct); + + var builder = ClaimsSnapshot.Create(); + + foreach (var role in roles) + builder.Add(ClaimTypes.Role, role); + + foreach (var perm in perms) + builder.Add("uauth:permission", perm.Value); + + return builder.Build(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs deleted file mode 100644 index e5dff350..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Domain; -using System.Security.Claims; - -namespace CodeBeam.UltimateAuth.Authorization -{ - public sealed class DefaultAuthorizationClaimsProvider : IUserClaimsProvider - { - private readonly IUserRoleStore _roles; - private readonly IUserPermissionStore _permissions; - - public DefaultAuthorizationClaimsProvider(IUserRoleStore roles, IUserPermissionStore permissions) - { - _roles = roles; - _permissions = permissions; - } - - public async Task GetClaimsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - var roles = await _roles.GetRolesAsync(tenantId, userKey, ct); - var perms = await _permissions.GetPermissionsAsync(tenantId, userKey, ct); - - var builder = ClaimsSnapshot.Create(); - - foreach (var role in roles) - builder.Add(ClaimTypes.Role, role); - - foreach (var perm in perms) - builder.Add("uauth:permission", perm.Value); - - return builder.Build(); - } - } - -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs index a4d8a2f6..9d01e8a2 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -1,11 +1,18 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialDto { - public sealed record CredentialDto( - CredentialType Type, - CredentialSecurityStatus Status, - DateTimeOffset CreatedAt, - DateTimeOffset? LastUsedAt, - DateTimeOffset? RestrictedUntil, - DateTimeOffset? ExpiresAt, - string? Source); + public CredentialType Type { get; init; } + + public CredentialSecurityStatus Status { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? LastUsedAt { get; init; } + + public DateTimeOffset? RestrictedUntil { get; init; } + + public DateTimeOffset? ExpiresAt { get; init; } + + public string? Source { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs index 134dee3b..a3fed36e 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs @@ -1,6 +1,8 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record CredentialMetadata( - DateTimeOffset CreatedAt, - DateTimeOffset? LastUsedAt, - string? Source); +public sealed record CredentialMetadata +{ + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastUsedAt { get; init; } + public string? Source { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs index 7b101443..d90804d7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs @@ -1,35 +1,34 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public static class CredentialTypeParser { - public static class CredentialTypeParser - { - private static readonly Dictionary _map = - new(StringComparer.OrdinalIgnoreCase) - { - ["password"] = CredentialType.Password, + private static readonly Dictionary _map = + new(StringComparer.OrdinalIgnoreCase) + { + ["password"] = CredentialType.Password, - ["otp"] = CredentialType.OneTimeCode, - ["one-time-code"] = CredentialType.OneTimeCode, + ["otp"] = CredentialType.OneTimeCode, + ["one-time-code"] = CredentialType.OneTimeCode, - ["email-otp"] = CredentialType.EmailOtp, - ["sms-otp"] = CredentialType.SmsOtp, + ["email-otp"] = CredentialType.EmailOtp, + ["sms-otp"] = CredentialType.SmsOtp, - ["totp"] = CredentialType.Totp, + ["totp"] = CredentialType.Totp, - ["passkey"] = CredentialType.Passkey, + ["passkey"] = CredentialType.Passkey, - ["certificate"] = CredentialType.Certificate, - ["cert"] = CredentialType.Certificate, + ["certificate"] = CredentialType.Certificate, + ["cert"] = CredentialType.Certificate, - ["api-key"] = CredentialType.ApiKey, - ["apikey"] = CredentialType.ApiKey, + ["api-key"] = CredentialType.ApiKey, + ["apikey"] = CredentialType.ApiKey, - ["external"] = CredentialType.External - }; + ["external"] = CredentialType.External + }; - public static bool TryParse(string value, out CredentialType type) => _map.TryGetValue(value, out type); + 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}'"); - } + 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/Request/AddCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs index dd26c9d3..89a903c5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record AddCredentialRequest() { - public sealed record AddCredentialRequest() - { - public CredentialType Type { get; set; } - public required string Secret { get; set; } - public string? Source { get; set; } - } + public CredentialType Type { get; set; } + public required string Secret { get; set; } + public string? Source { get; set; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs index bd6cd4be..98eb2e4c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record BeginCredentialResetRequest { - public sealed record BeginCredentialResetRequest - { - public string? Reason { get; init; } - } + public string? Reason { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs index 6afdea41..7dcaf3da 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CompleteCredentialResetRequest { - public sealed record CompleteCredentialResetRequest - { - public required string NewSecret { get; init; } - public string? Source { get; init; } - } + public required string NewSecret { get; init; } + public string? Source { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs index 1e144536..a895d5e7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs @@ -1,15 +1,14 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ResetPasswordRequest { - public sealed record ResetPasswordRequest - { - public UserKey UserKey { get; init; } = default!; - public required string NewPassword { get; init; } + public UserKey UserKey { get; init; } = default!; + public required string NewPassword { get; init; } - /// - /// Optional reset token or verification code. - /// - public string? Token { get; init; } - } + /// + /// Optional reset token or verification code. + /// + public string? Token { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs index dda45bf3..855b606e 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed class RevokeAllCredentialsRequest { - public sealed class RevokeAllCredentialsRequest - { - public required UserKey UserKey { get; init; } - } + public required UserKey UserKey { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs index ad049edc..108fa25c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs @@ -1,6 +1,15 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record RevokeCredentialRequest { - public sealed record RevokeCredentialRequest( - DateTimeOffset? Until = null, - string? Reason = null); + /// + /// 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 index b6956b44..dd9f3daa 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs @@ -1,14 +1,24 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record AddCredentialResult { - public sealed record AddCredentialResult( - bool Succeeded, - string? Error, - CredentialType? Type = null) - { - public static AddCredentialResult Success(CredentialType type) - => new(true, null, type); - - public static AddCredentialResult Fail(string error) - => new(false, error); - } + public bool Succeeded { get; init; } + + public string? Error { get; init; } + + public CredentialType? Type { get; init; } + + public static AddCredentialResult Success(CredentialType type) + => new() + { + Succeeded = true, + Type = type + }; + + public static AddCredentialResult Fail(string error) + => new() + { + Succeeded = false, + Error = error + }; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs index 59250862..8579bc23 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs @@ -1,13 +1,24 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record ChangeCredentialResult( - bool Succeeded, - string? Error, - CredentialType? Type = null) +public sealed record ChangeCredentialResult { + public bool Succeeded { get; init; } + + public string? Error { get; init; } + + public CredentialType? Type { get; init; } + public static ChangeCredentialResult Success(CredentialType type) - => new(true, null, type); + => new() + { + Succeeded = true, + Type = type + }; public static ChangeCredentialResult Fail(string error) - => new(false, error); + => new() + { + Succeeded = 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 index acdac294..f45a6659 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs @@ -1,13 +1,21 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialActionResult { - public sealed record CredentialActionResult( - bool Succeeded, - string? Error) - { - public static CredentialActionResult Success() - => new(true, null); - - public static CredentialActionResult Fail(string error) - => new(false, error); - } + 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 index 9ccd3c79..ad00b575 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs @@ -1,6 +1,13 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record CredentialChangeResult( - bool Succeeded, - bool SecurityInvalidated, - string? FailureReason = null); +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 index 4952fda4..36d9ae8d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs @@ -14,8 +14,6 @@ public sealed record CredentialProvisionResult public string? FailureReason { get; init; } - /* ----------------- Helpers ----------------- */ - public static CredentialProvisionResult Success(CredentialType type) => new() { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs index e861b999..8cb7ea61 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs @@ -1,33 +1,36 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialValidationResult { - public sealed record CredentialValidationResult( - bool IsValid, - bool RequiresReauthentication, - bool RequiresSecurityVersionIncrement, - string? FailureReason = null) - { - public static CredentialValidationResult Success( - bool requiresSecurityVersionIncrement = false) - => new( - IsValid: true, - RequiresReauthentication: false, - RequiresSecurityVersionIncrement: requiresSecurityVersionIncrement); + 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, - RequiresSecurityVersionIncrement: false, - FailureReason: reason); + 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, - RequiresSecurityVersionIncrement: false, - 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/CredentialValidationResultDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs deleted file mode 100644 index c8018a51..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed record CredentialValidationResultDto -{ - public bool IsValid { get; init; } - - public bool RequiresReauthentication { get; init; } - public bool RequiresSecurityVersionIncrement { get; init; } - - public string? FailureReason { get; init; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs index 0ad73e96..ce621456 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs @@ -1,5 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record GetCredentialsResult { - public sealed record GetCredentialsResult( - IReadOnlyCollection Credentials); + public IReadOnlyCollection Credentials { get; init; } = Array.Empty(); } 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 index 4ffa082d..fcba3cc0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -6,6 +6,7 @@ enable 0.0.1-preview true + $(NoWarn);1591 diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs index 4b2c9f2e..08378ed6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs @@ -1,24 +1,23 @@ using System.Linq.Expressions; -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal static class ConventionResolver { - internal static class ConventionResolver + public static Expression>? TryResolve(params string[] names) { - public static Expression>? TryResolve(params string[] names) - { - var prop = typeof(TUser) - .GetProperties() - .FirstOrDefault(p => - names.Contains(p.Name, StringComparer.OrdinalIgnoreCase) && - typeof(TProp).IsAssignableFrom(p.PropertyType)); + var prop = typeof(TUser) + .GetProperties() + .FirstOrDefault(p => + names.Contains(p.Name, StringComparer.OrdinalIgnoreCase) && + typeof(TProp).IsAssignableFrom(p.PropertyType)); - if (prop is null) - return null; + if (prop is null) + return null; - var param = Expression.Parameter(typeof(TUser), "u"); - var body = Expression.Property(param, prop); + var param = Expression.Parameter(typeof(TUser), "u"); + var body = Expression.Property(param, prop); - return Expression.Lambda>(body, param); - } + return Expression.Lambda>(body, param); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs index dfb653c7..4baa14c7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs @@ -1,75 +1,74 @@ -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal static class CredentialUserMappingBuilder { - internal static class CredentialUserMappingBuilder + public static CredentialUserMapping Build(CredentialUserMappingOptions options) { - public static CredentialUserMapping Build(CredentialUserMappingOptions options) + if (options.UserId is null) { - if (options.UserId is null) - { - var expr = ConventionResolver.TryResolve("Id", "UserId"); - if (expr != null) - options.ApplyUserId(expr); - } + var expr = ConventionResolver.TryResolve("Id", "UserId"); + if (expr != null) + options.ApplyUserId(expr); + } - if (options.Username is null) - { - var expr = ConventionResolver.TryResolve( - "Username", - "UserName", - "Email", - "EmailAddress", - "Login"); + if (options.Username is null) + { + var expr = ConventionResolver.TryResolve( + "Username", + "UserName", + "Email", + "EmailAddress", + "Login"); - if (expr != null) - options.ApplyUsername(expr); - } + if (expr != null) + options.ApplyUsername(expr); + } - // Never add "Password" as a convention to avoid accidental mapping to plaintext password properties - if (options.PasswordHash is null) - { - var expr = ConventionResolver.TryResolve( - "PasswordHash", - "Passwordhash", - "PasswordHashV2"); + // Never add "Password" as a convention to avoid accidental mapping to plaintext password properties + if (options.PasswordHash is null) + { + var expr = ConventionResolver.TryResolve( + "PasswordHash", + "Passwordhash", + "PasswordHashV2"); - if (expr != null) - options.ApplyPasswordHash(expr); - } + if (expr != null) + options.ApplyPasswordHash(expr); + } - if (options.SecurityVersion is null) - { - var expr = ConventionResolver.TryResolve( - "SecurityVersion", - "SecurityStamp", - "AuthVersion"); + if (options.SecurityVersion is null) + { + var expr = ConventionResolver.TryResolve( + "SecurityVersion", + "SecurityStamp", + "AuthVersion"); - if (expr != null) - options.ApplySecurityVersion(expr); - } + if (expr != null) + options.ApplySecurityVersion(expr); + } - if (options.UserId is null) - throw new InvalidOperationException("UserId mapping is required. Use MapUserId(...) or ensure a conventional property exists."); + if (options.UserId is null) + throw new InvalidOperationException("UserId mapping is required. Use MapUserId(...) or ensure a conventional property exists."); - if (options.Username is null) - throw new InvalidOperationException("Username mapping is required. Use MapUsername(...) or ensure a conventional property exists."); + if (options.Username is null) + throw new InvalidOperationException("Username mapping is required. Use MapUsername(...) or ensure a conventional property exists."); - if (options.PasswordHash is null) - throw new InvalidOperationException("PasswordHash mapping is required. Use MapPasswordHash(...) or ensure a conventional property exists."); + if (options.PasswordHash is null) + throw new InvalidOperationException("PasswordHash mapping is required. Use MapPasswordHash(...) or ensure a conventional property exists."); - if (options.SecurityVersion is null) - throw new InvalidOperationException("SecurityVersion mapping is required. Use MapSecurityVersion(...) or ensure a conventional property exists."); + if (options.SecurityVersion is null) + throw new InvalidOperationException("SecurityVersion mapping is required. Use MapSecurityVersion(...) or ensure a conventional property exists."); - var canAuthenticateExpr = options.CanAuthenticate ?? (_ => true); + var canAuthenticateExpr = options.CanAuthenticate ?? (_ => true); - return new CredentialUserMapping - { - UserId = options.UserId.Compile(), - Username = options.Username.Compile(), - PasswordHash = options.PasswordHash.Compile(), - SecurityVersion = options.SecurityVersion.Compile(), - CanAuthenticate = canAuthenticateExpr.Compile() - }; - } + return new CredentialUserMapping + { + UserId = options.UserId.Compile(), + Username = options.Username.Compile(), + PasswordHash = options.PasswordHash.Compile(), + SecurityVersion = options.SecurityVersion.Compile(), + CanAuthenticate = canAuthenticateExpr.Compile() + }; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs index 7c91dd85..61a8b904 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCoreAuthUser : IAuthSubject { - internal sealed class EfCoreAuthUser : IAuthSubject - { - public TUserId UserId { get; } + public TUserId UserId { get; } - IReadOnlyDictionary? IAuthSubject.Claims => null; + IReadOnlyDictionary? IAuthSubject.Claims => null; - public EfCoreAuthUser(TUserId userId) - { - UserId = userId; - } + public EfCoreAuthUser(TUserId userId) + { + UserId = userId; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs deleted file mode 100644 index 1ede89b8..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs +++ /dev/null @@ -1,83 +0,0 @@ -//using CodeBeam.UltimateAuth.Core.Abstractions; -//using CodeBeam.UltimateAuth.Core.Domain; -//using CodeBeam.UltimateAuth.Core.Infrastructure; -//using Microsoft.EntityFrameworkCore; -//using Microsoft.Extensions.Options; - -//namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -//internal sealed class EfCoreUserStore : IUAuthUserStore where TUser : class -//{ -// private readonly DbContext _db; -// private readonly CredentialUserMapping _map; - -// public EfCoreUserStore(DbContext db, IOptions> options) -// { -// _db = db; -// _map = CredentialUserMappingBuilder.Build(options.Value); -// } - -// public async Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default) -// { -// var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); - -// if (user is null || !_map.CanAuthenticate(user)) -// return null; - -// return new EfCoreAuthUser(_map.UserId(user)); -// } - -// public async Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default) -// { -// var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == username, ct); - -// if (user is null || !_map.CanAuthenticate(user)) -// return null; - -// return new UserRecord -// { -// Id = _map.UserId(user), -// Username = _map.Username(user), -// PasswordHash = _map.PasswordHash(user), -// IsActive = true, -// CreatedAt = DateTimeOffset.UtcNow, -// IsDeleted = false -// }; -// } - -// public async Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default) -// { -// var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == login, ct); - -// if (user is null || !_map.CanAuthenticate(user)) -// return null; - -// return new EfCoreAuthUser(_map.UserId(user)); -// } - -// public Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken ct = default) -// { -// return _db.Set() -// .Where(u => _map.UserId(u)!.Equals(userId)) -// .Select(u => _map.PasswordHash(u)) -// .FirstOrDefaultAsync(ct); -// } - -// public Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default) -// { -// throw new NotSupportedException("Password updates are not supported by EfCoreUserStore. " + -// "Use application-level user management services."); -// } - -// public async Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken ct = default) -// { -// var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); -// return user is null ? 0 : _map.SecurityVersion(user); -// } - -// public Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default) -// { -// throw new NotSupportedException("Security version updates must be handled by the application."); -// } - -//} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs index 060b8665..97442ecb 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -1,19 +1,13 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthEfCoreCredentials( - this IServiceCollection services, - Action> configure) - where TUser : class + public static IServiceCollection AddUltimateAuthEfCoreCredentials(this IServiceCollection services, Action> configure) where TUser : class { services.Configure(configure); - //services.AddScoped, EfCoreUserStore>(); - return services; } } 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 index dbc070bb..944f0a0a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj @@ -6,6 +6,7 @@ enable 0.0.1-preview true + $(NoWarn);1591 diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index 468092e2..6d51c4f1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -1,45 +1,45 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; -namespace CodeBeam.UltimateAuth.Credentials.InMemory +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +internal sealed class InMemoryCredentialSeedContributor : ISeedContributor { - internal sealed class InMemoryCredentialSeedContributor : ISeedContributor - { - public int Order => 10; + public int Order => 10; - private readonly ICredentialStore _credentials; - private readonly IInMemoryUserIdProvider _ids; - private readonly IUAuthPasswordHasher _hasher; + private readonly ICredentialStore _credentials; + private readonly IInMemoryUserIdProvider _ids; + private readonly IUAuthPasswordHasher _hasher; - public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) - { - _credentials = credentials; - _ids = ids; - _hasher = hasher; - } + public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) + { + _credentials = credentials; + _ids = ids; + _hasher = hasher; + } - public async Task SeedAsync(string? tenantId, CancellationToken ct = default) - { - await SeedCredentialAsync("admin", _ids.GetAdminUserId(), tenantId, ct); - await SeedCredentialAsync("user", _ids.GetUserUserId(), tenantId, ct); - } + public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) + { + await SeedCredentialAsync("admin", _ids.GetAdminUserId(), tenant, ct); + await SeedCredentialAsync("user", _ids.GetUserUserId(), tenant, ct); + } - private async Task SeedCredentialAsync(string login, UserKey userKey, string? tenantId, CancellationToken ct) - { - if (await _credentials.ExistsAsync(tenantId, userKey, CredentialType.Password, ct)) - return; + private async Task SeedCredentialAsync(string login, UserKey userKey, TenantKey tenant, CancellationToken ct) + { + if (await _credentials.ExistsAsync(tenant, userKey, CredentialType.Password, ct)) + return; - await _credentials.AddAsync(tenantId, - new PasswordCredential( - userKey, - login, - _hasher.Hash(login), - CredentialSecurityState.Active, - new CredentialMetadata(DateTimeOffset.Now, null, null)), - ct); - } + await _credentials.AddAsync(tenant, + new PasswordCredential( + userKey, + login, + _hasher.Hash(login), + CredentialSecurityState.Active, + new CredentialMetadata { CreatedAt = DateTimeOffset.Now}), + ct); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 8bc957b0..66d3d7bf 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -1,8 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Credentials.InMemory; using CodeBeam.UltimateAuth.Credentials.Reference; using System.Collections.Concurrent; @@ -10,8 +9,8 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory; internal sealed class InMemoryCredentialStore : ICredentialStore, ICredentialSecretStore where TUserId : notnull { - private readonly ConcurrentDictionary<(string? TenantId, string Login), InMemoryPasswordCredentialState> _byLogin; - private readonly ConcurrentDictionary<(string? TenantId, TUserId UserId), List>> _byUser; + private readonly ConcurrentDictionary<(TenantKey Tenant, string Login), InMemoryPasswordCredentialState> _byLogin; + private readonly ConcurrentDictionary<(TenantKey Tenant, TUserId UserId), List>> _byUser; private readonly IUAuthPasswordHasher _hasher; private readonly IInMemoryUserIdProvider _userIdProvider; @@ -21,35 +20,35 @@ public InMemoryCredentialStore(IUAuthPasswordHasher hasher, IInMemoryUserIdProvi _hasher = hasher; _userIdProvider = userIdProvider; - _byLogin = new ConcurrentDictionary<(string?, string), InMemoryPasswordCredentialState>(); - _byUser = new ConcurrentDictionary<(string?, TUserId), List>>(); + _byLogin = new ConcurrentDictionary<(TenantKey, string), InMemoryPasswordCredentialState>(); + _byUser = new ConcurrentDictionary<(TenantKey, TUserId), List>>(); } - public Task>> FindByLoginAsync(string? tenantId, string loginIdentifier, CancellationToken ct = default) + public Task>> FindByLoginAsync(TenantKey tenant, string loginIdentifier, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byLogin.TryGetValue((tenantId, loginIdentifier), out var state)) + if (!_byLogin.TryGetValue((tenant, loginIdentifier), out var state)) return Task.FromResult>>(Array.Empty>()); return Task.FromResult>>(new[] { Map(state) }); } - public Task>> GetByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + public Task>> GetByUserAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenantId, userId), out var list)) + if (!_byUser.TryGetValue((tenant, userId), out var list)) return Task.FromResult>>(Array.Empty>()); return Task.FromResult>>(list.Select(Map).ToArray()); } - public Task>> GetByUserAndTypeAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default) + public Task>> GetByUserAndTypeAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenantId, userId), out var list)) + if (!_byUser.TryGetValue((tenant, userId), out var list)) return Task.FromResult>>(Array.Empty>()); return Task.FromResult>>( @@ -58,14 +57,14 @@ public Task>> GetByUserAndTypeAsync(str .ToArray()); } - public Task ExistsAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default) + public Task ExistsAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - return Task.FromResult(_byUser.TryGetValue((tenantId, userId), out var list) && list.Any(c => c.Type == type)); + return Task.FromResult(_byUser.TryGetValue((tenant, userId), out var list) && list.Any(c => c.Type == type)); } - public Task AddAsync(string? tenantId, ICredential credential, CancellationToken ct = default) + public Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -81,10 +80,10 @@ public Task AddAsync(string? tenantId, ICredential credential, Cancella Metadata = pwd.Metadata }; - _byLogin[(tenantId, pwd.LoginIdentifier)] = state; + _byLogin[(tenant, pwd.LoginIdentifier)] = state; _byUser.AddOrUpdate( - (tenantId, pwd.UserId), + (tenant, pwd.UserId), _ => new List> { state }, (_, list) => { @@ -95,11 +94,11 @@ public Task AddAsync(string? tenantId, ICredential credential, Cancella return Task.CompletedTask; } - public Task UpdateSecurityStateAsync(string? tenantId, TUserId userId, CredentialType type, CredentialSecurityState securityState, CancellationToken ct = default) + public Task UpdateSecurityStateAsync(TenantKey tenant, TUserId userId, CredentialType type, CredentialSecurityState securityState, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue((tenantId, userId), out var list)) + if (_byUser.TryGetValue((tenant, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -109,11 +108,11 @@ public Task UpdateSecurityStateAsync(string? tenantId, TUserId userId, Credentia return Task.CompletedTask; } - public Task UpdateMetadataAsync(string? tenantId, TUserId userId, CredentialType type, CredentialMetadata metadata, CancellationToken ct = default) + public Task UpdateMetadataAsync(TenantKey tenant, TUserId userId, CredentialType type, CredentialMetadata metadata, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue((tenantId, userId), out var list)) + if (_byUser.TryGetValue((tenant, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -123,11 +122,11 @@ public Task UpdateMetadataAsync(string? tenantId, TUserId userId, CredentialType return Task.CompletedTask; } - public Task SetAsync(string? tenantId, TUserId userId, CredentialType type, string secretHash, CancellationToken ct = default) + public Task SetAsync(TenantKey tenant, TUserId userId, CredentialType type, string secretHash, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue((tenantId, userId), out var list)) + if (_byUser.TryGetValue((tenant, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -137,31 +136,31 @@ public Task SetAsync(string? tenantId, TUserId userId, CredentialType type, stri return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default) + public Task DeleteAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue((tenantId, userId), out var list)) + if (_byUser.TryGetValue((tenant, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) { list.Remove(state); - _byLogin.TryRemove((tenantId, state.Login), out _); + _byLogin.TryRemove((tenant, state.Login), out _); } } return Task.CompletedTask; } - public Task DeleteByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + public Task DeleteByUserAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryRemove((tenantId, userId), out var list)) + if (_byUser.TryRemove((tenant, userId), out var list)) { foreach (var credential in list) - _byLogin.TryRemove((tenantId, credential.Login), out _); + _byLogin.TryRemove((tenant, credential.Login), out _); } return Task.CompletedTask; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs index c9c06d7d..b9da6751 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Credentials.InMemory +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +internal sealed class InMemoryPasswordCredentialState { - internal sealed class InMemoryPasswordCredentialState - { - public TUserId UserId { get; init; } = default!; - public CredentialType Type { get; } = CredentialType.Password; + public TUserId UserId { get; init; } = default!; + public CredentialType Type { get; } = CredentialType.Password; - public string Login { get; init; } = default!; - public string SecretHash { get; set; } = default!; + public string Login { get; init; } = default!; + public string SecretHash { get; set; } = default!; - public CredentialSecurityState Security { get; set; } = default!; - public CredentialMetadata Metadata { get; set; } = default!; - } + public CredentialSecurityState Security { get; set; } = default!; + public CredentialMetadata Metadata { get; set; } = default!; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs similarity index 92% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs index 422d72a1..8385ec31 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory.Extensions { - public static class UltimateAuthCredentialsInMemoryExtensions + public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs index 78526c99..d496e7f7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs index 60fa352d..e0426f87 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs @@ -1,20 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Credentials.Reference -{ - internal sealed class AddCredentialCommand : IAccessCommand - { - private readonly Func> _execute; +namespace CodeBeam.UltimateAuth.Credentials.Reference; - public AddCredentialCommand(Func> execute) - { - _execute = execute; - } +internal sealed class AddCredentialCommand : IAccessCommand +{ + private readonly Func> _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public AddCredentialCommand(Func> execute) + { + _execute = execute; } + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs index e3e6fd7a..7cd3bde0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs index 14b476de..f9117ea8 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs @@ -1,5 +1,4 @@ - -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs index 7c3461ce..098d01bc 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs @@ -1,17 +1,16 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Credentials.Reference -{ - internal sealed class GetAllCredentialsCommand : IAccessCommand - { - private readonly Func> _execute; +namespace CodeBeam.UltimateAuth.Credentials.Reference; - public GetAllCredentialsCommand(Func> execute) - { - _execute = execute; - } +internal sealed class GetAllCredentialsCommand : IAccessCommand +{ + private readonly Func> _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public GetAllCredentialsCommand(Func> execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs index 113e914d..4efed9f9 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Credentials.Reference -{ - internal sealed class SetInitialCredentialCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Credentials.Reference; - public SetInitialCredentialCommand(Func execute) - { - _execute = execute; - } +internal sealed class SetInitialCredentialCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public SetInitialCredentialCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs similarity index 97% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index 25ea1b96..10b8092f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -8,16 +8,13 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class DefaultCredentialEndpointHandler : ICredentialEndpointHandler +public sealed class CredentialEndpointHandler : ICredentialEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAccessContextFactory _accessContextFactory; private readonly IUserCredentialsService _credentials; - public DefaultCredentialEndpointHandler( - IAuthFlowContextAccessor authFlow, - IAccessContextFactory accessContextFactory, - IUserCredentialsService credentials) + public CredentialEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserCredentialsService credentials) { _authFlow = authFlow; _accessContextFactory = accessContextFactory; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index 0f191c3f..466afb4c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -10,9 +10,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); return services; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index 29084e7b..da6165c6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -1,6 +1,7 @@ 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.Abstractions; using CodeBeam.UltimateAuth.Users.Contracts; @@ -20,7 +21,7 @@ public PasswordUserLifecycleIntegration(ICredentialStore credentialStor _clock = clock; } - public async Task OnUserCreatedAsync(string? tenantId, UserKey userKey, object request, CancellationToken ct) + public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, CancellationToken ct) { if (request is not CreateUserRequest r) return; @@ -35,13 +36,13 @@ public async Task OnUserCreatedAsync(string? tenantId, UserKey userKey, object r loginIdentifier: r.PrimaryIdentifierValue!, secretHash: hash, security: new CredentialSecurityState(CredentialSecurityStatus.Active, null, null, null), - metadata: new CredentialMetadata(_clock.UtcNow, _clock.UtcNow, null)); + metadata: new CredentialMetadata { CreatedAt = _clock.UtcNow, LastUsedAt = _clock.UtcNow }); - await _credentialStore.AddAsync(tenantId, credential, ct); + await _credentialStore.AddAsync(tenant, credential, ct); } - public async Task OnUserDeletedAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct) + public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) { - await _credentialStore.DeleteByUserAsync(tenantId, userKey, ct); + await _credentialStore.DeleteByUserAsync(tenant, userKey, ct); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs index 568b7cfc..05035c5e 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs @@ -1,10 +1,10 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Credentials.Reference.Internal +namespace CodeBeam.UltimateAuth.Credentials.Reference.Internal; + +internal interface IUserCredentialsInternalService { - internal interface IUserCredentialsInternalService - { - Task DeleteInternalAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); - } + Task DeleteInternalAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs similarity index 83% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs index 99f0b219..02a12085 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs @@ -1,13 +1,14 @@ 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.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference; -internal sealed class DefaultUserCredentialsService : IUserCredentialsService, IUserCredentialsInternalService +internal sealed class UserCredentialsService : IUserCredentialsService, IUserCredentialsInternalService { private readonly IAccessOrchestrator _accessOrchestrator; private readonly ICredentialStore _credentials; @@ -15,7 +16,7 @@ internal sealed class DefaultUserCredentialsService : IUserCredentialsService, I private readonly IUAuthPasswordHasher _hasher; private readonly IClock _clock; - public DefaultUserCredentialsService( + public UserCredentialsService( IAccessOrchestrator accessOrchestrator, ICredentialStore credentials, ICredentialSecretStore secrets, @@ -39,21 +40,24 @@ public async Task GetAllAsync(AccessContext context, Cance if (context.ActorUserKey is not UserKey userKey) throw new UnauthorizedAccessException(); - var creds = await _credentials.GetByUserAsync(context.ResourceTenantId, userKey, innerCt); + var creds = await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt); var dtos = creds .OfType() - .Select(c => new CredentialDto( - c.Type, - c.Security.Status, - c.Metadata.CreatedAt, - c.Metadata.LastUsedAt, - c.Security.RestrictedUntil, - c.Security.ExpiresAt, - c.Metadata.Source)) + .Select(c => new CredentialDto { + Type = c.Type, + Status = c.Security.Status, + CreatedAt = c.Metadata.CreatedAt, + LastUsedAt = c.Metadata.LastUsedAt, + RestrictedUntil = c.Security.RestrictedUntil, + ExpiresAt = c.Security.ExpiresAt, + Source = c.Metadata.Source}) .ToArray(); - return new GetCredentialsResult(dtos); + return new GetCredentialsResult + { + Credentials = dtos + }; }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -71,7 +75,7 @@ public async Task AddAsync(AccessContext context, AddCreden if (context.ActorUserKey is not UserKey userKey) throw new UnauthorizedAccessException(); - var exists = await _credentials.ExistsAsync(context.ResourceTenantId, userKey, request.Type, innerCt); + var exists = await _credentials.ExistsAsync(context.ResourceTenant, userKey, request.Type, innerCt); if (exists) return AddCredentialResult.Fail("credential_already_exists"); @@ -83,12 +87,9 @@ public async Task AddAsync(AccessContext context, AddCreden loginIdentifier: userKey.Value, secretHash: hash, security: new CredentialSecurityState(CredentialSecurityStatus.Active), - metadata: new CredentialMetadata( - _clock.UtcNow, - null, - request.Source)); + metadata: new CredentialMetadata { CreatedAt = _clock.UtcNow, Source = request.Source }); - await _credentials.AddAsync(context.ResourceTenantId, credential, innerCt); + await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); return AddCredentialResult.Success(request.Type); }); @@ -110,7 +111,7 @@ public async Task ChangeAsync(AccessContext context, Cre var hash = _hasher.Hash(request.NewSecret); - await _secrets.SetAsync(context.ResourceTenantId, userKey, type, hash, innerCt); + await _secrets.SetAsync(context.ResourceTenant, userKey, type, hash, innerCt); return ChangeCredentialResult.Success(type); }); @@ -135,7 +136,7 @@ public async Task RevokeAsync(AccessContext context, Cre expiresAt: null, reason: request.Reason); - await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + await _credentials.UpdateSecurityStateAsync(context.ResourceTenant, userKey, type, security, innerCt); return CredentialActionResult.Success(); }); @@ -153,7 +154,7 @@ public async Task ActivateAsync(AccessContext context, C throw new UnauthorizedAccessException(); var security = new CredentialSecurityState(CredentialSecurityStatus.Active); - await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + await _credentials.UpdateSecurityStateAsync(context.ResourceTenant, userKey, type, security, innerCt); return CredentialActionResult.Success(); }); @@ -170,7 +171,7 @@ public async Task BeginResetAsync(AccessContext context, CredentialType type, Be var security = new CredentialSecurityState(CredentialSecurityStatus.ResetRequested, reason: request.Reason); - await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + await _credentials.UpdateSecurityStateAsync(context.ResourceTenant, userKey, type, security, innerCt); return CredentialActionResult.Success(); }); @@ -186,10 +187,10 @@ public async Task CompleteResetAsync(AccessContext context, CredentialType type, var hash = _hasher.Hash(request.NewSecret); - await _secrets.SetAsync(context.ResourceTenantId, userKey, type, hash, innerCt); + await _secrets.SetAsync(context.ResourceTenant, userKey, type, hash, innerCt); var security = new CredentialSecurityState(CredentialSecurityStatus.Active); - await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + await _credentials.UpdateSecurityStateAsync(context.ResourceTenant, userKey, type, security, innerCt); return CredentialActionResult.Success(); }); @@ -206,7 +207,7 @@ public async Task DeleteAsync(AccessContext context, Cre if (context.ActorUserKey is not UserKey userKey) throw new UnauthorizedAccessException(); - await _credentials.DeleteAsync(context.ResourceTenantId, userKey, type, innerCt); + await _credentials.DeleteAsync(context.ResourceTenant, userKey, type, innerCt); return CredentialActionResult.Success(); }); @@ -216,11 +217,11 @@ public async Task DeleteAsync(AccessContext context, Cre // ---------------------------------------- // INTERNAL ONLY - NEVER CALL THEM DIRECTLY // ---------------------------------------- - async Task IUserCredentialsInternalService.DeleteInternalAsync(string? tenantId, UserKey userKey, CancellationToken ct) + async Task IUserCredentialsInternalService.DeleteInternalAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) { ct.ThrowIfCancellationRequested(); - await _credentials.DeleteByUserAsync(tenantId, userKey, ct); + await _credentials.DeleteByUserAsync(tenant, userKey, ct); return CredentialActionResult.Success(); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs index 82a654a1..c8a4af55 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs @@ -1,11 +1,10 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Credentials +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialDescriptor { - public interface ICredentialDescriptor - { - CredentialType Type { get; } - CredentialSecurityState Security { get; } - CredentialMetadata Metadata { get; } - } + CredentialType Type { get; } + CredentialSecurityState Security { get; } + CredentialMetadata Metadata { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs index f74ded1c..2686afc3 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs @@ -1,9 +1,9 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Credentials +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialSecretStore { - public interface ICredentialSecretStore - { - Task SetAsync(string? tenantId, TUserId userId, CredentialType type, string secretHash, CancellationToken ct = default); - } + Task SetAsync(TenantKey tenant, TUserId userId, CredentialType type, string secretHash, CancellationToken ct = default); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs index 7429b1eb..0d1f87f3 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -1,17 +1,17 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Credentials +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialStore { - public interface ICredentialStore - { - Task>>FindByLoginAsync(string? tenantId, string loginIdentifier, CancellationToken ct = default); - Task>>GetByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); - Task>>GetByUserAndTypeAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default); - Task AddAsync(string? tenantId, ICredential credential, CancellationToken ct = default); - Task UpdateSecurityStateAsync(string? tenantId, TUserId userId, CredentialType type, CredentialSecurityState securityState, CancellationToken ct = default); - Task UpdateMetadataAsync(string? tenantId, TUserId userId, CredentialType type, CredentialMetadata metadata, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default); - Task DeleteByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); - Task ExistsAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default); - } + Task>>FindByLoginAsync(TenantKey tenant, string loginIdentifier, CancellationToken ct = default); + Task>>GetByUserAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); + Task>>GetByUserAndTypeAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default); + Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); + Task UpdateSecurityStateAsync(TenantKey tenant, TUserId userId, CredentialType type, CredentialSecurityState securityState, CancellationToken ct = default); + Task UpdateMetadataAsync(TenantKey tenant, TUserId userId, CredentialType type, CredentialMetadata metadata, CancellationToken ct = default); + Task DeleteAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default); + Task DeleteByUserAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); + Task ExistsAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs index 0902d43b..e6f6a21d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials +namespace CodeBeam.UltimateAuth.Credentials; + +public interface IPublicKeyCredential : ICredential { - public interface IPublicKeyCredential : ICredential - { - byte[] PublicKey { get; } - } + byte[] PublicKey { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs index 89989e16..ceb20f58 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Credentials +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ISecurableCredential { - public interface ISecurableCredential - { - CredentialSecurityState Security { get; } - } + CredentialSecurityState Security { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs similarity index 88% rename from src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs index 4416aef0..4071d4a7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs @@ -3,12 +3,12 @@ namespace CodeBeam.UltimateAuth.Credentials; -public sealed class DefaultCredentialValidator : ICredentialValidator +public sealed class CredentialValidator : ICredentialValidator { private readonly IUAuthPasswordHasher _passwordHasher; private readonly IClock _clock; - public DefaultCredentialValidator(IUAuthPasswordHasher passwordHasher, IClock clock) + public CredentialValidator(IUAuthPasswordHasher passwordHasher, IClock clock) { _passwordHasher = passwordHasher; _clock = clock; diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj index eb3dffea..e03d7456 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -14,9 +14,4 @@ - - - - - diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs index 7450d420..0d592554 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs @@ -2,33 +2,32 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies.Registry; -namespace CodeBeam.UltimateAuth.Policies.Defaults +namespace CodeBeam.UltimateAuth.Policies.Defaults; + +public sealed class CompiledAccessPolicySet { - public sealed class CompiledAccessPolicySet + private readonly PolicyRegistration[] _registrations; + + internal CompiledAccessPolicySet(PolicyRegistration[] registrations) { - private readonly PolicyRegistration[] _registrations; + _registrations = registrations; + } - internal CompiledAccessPolicySet(PolicyRegistration[] registrations) - { - _registrations = registrations; - } + public IReadOnlyList Resolve(AccessContext context, IServiceProvider services) + { + var list = new List(); - public IReadOnlyList Resolve(AccessContext context, IServiceProvider services) + foreach (var r in _registrations) { - var list = new List(); - - foreach (var r in _registrations) + if (context.Action.StartsWith(r.ActionPrefix, StringComparison.OrdinalIgnoreCase)) { - if (context.Action.StartsWith(r.ActionPrefix, StringComparison.OrdinalIgnoreCase)) - { - var policy = r.Factory(services); + var policy = r.Factory(services); - if (policy.AppliesTo(context)) - list.Add(policy); - } + if (policy.AppliesTo(context)) + list.Add(policy); } - - return list; } + + return list; } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs index 7cdf491a..0a0fd764 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs @@ -1,32 +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; - } +namespace CodeBeam.UltimateAuth.Policies; - public IPolicyScopeBuilder Then() - => new ConditionalScopeBuilder(_prefix, _condition, true, _registry, _services); +internal sealed class ConditionalPolicyBuilder : IConditionalPolicyBuilder +{ + private readonly string _prefix; + private readonly Func _condition; + private readonly AccessPolicyRegistry _registry; + private readonly IServiceProvider _services; - public IPolicyScopeBuilder Otherwise() - => new ConditionalScopeBuilder(_prefix, _condition, false, _registry, _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 index b9e832ca..594f2723 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs @@ -3,44 +3,34 @@ 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 RequireAdmin() - => Add(); +namespace CodeBeam.UltimateAuth.Policies; - public IPolicyScopeBuilder RequireSelfOrAdmin() - => Add(); +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 IPolicyScopeBuilder RequireAuthenticated() - => Add(); + public ConditionalScopeBuilder(string prefix, Func condition, bool expected, AccessPolicyRegistry registry, IServiceProvider services) + { + _prefix = prefix; + _condition = condition; + _expected = expected; + _registry = registry; + _services = services; + } - public IPolicyScopeBuilder DenyCrossTenant() - => Add(); + 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 RequireAdmin() => Add(); + public IPolicyScopeBuilder RequireSelfOrAdmin() => 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 index 3444934f..64b0ba3c 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IConditionalPolicyBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IConditionalPolicyBuilder.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Policies +namespace CodeBeam.UltimateAuth.Policies; + +public interface IConditionalPolicyBuilder { - public interface IConditionalPolicyBuilder - { - IPolicyScopeBuilder Then(); - IPolicyScopeBuilder Otherwise(); - } + IPolicyScopeBuilder Then(); + IPolicyScopeBuilder Otherwise(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs index 22d2eede..d265c7e6 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Policies +namespace CodeBeam.UltimateAuth.Policies; + +public interface IPolicyBuilder { - public interface IPolicyBuilder - { - IPolicyScopeBuilder For(string actionPrefix); - IPolicyScopeBuilder Global(); - } + 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 index 44c2faed..1916eeee 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs @@ -1,11 +1,10 @@ -namespace CodeBeam.UltimateAuth.Policies +namespace CodeBeam.UltimateAuth.Policies; + +public interface IPolicyScopeBuilder { - public interface IPolicyScopeBuilder - { - IPolicyScopeBuilder RequireAuthenticated(); - IPolicyScopeBuilder RequireSelf(); - IPolicyScopeBuilder RequireAdmin(); - IPolicyScopeBuilder RequireSelfOrAdmin(); - IPolicyScopeBuilder DenyCrossTenant(); - } + IPolicyScopeBuilder RequireAuthenticated(); + IPolicyScopeBuilder RequireSelf(); + IPolicyScopeBuilder RequireAdmin(); + IPolicyScopeBuilder RequireSelfOrAdmin(); + IPolicyScopeBuilder DenyCrossTenant(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs index 2ecc488c..4f3f8afc 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs @@ -1,20 +1,19 @@ using CodeBeam.UltimateAuth.Policies.Registry; -namespace CodeBeam.UltimateAuth.Policies +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class PolicyBuilder : IPolicyBuilder { - internal sealed class PolicyBuilder : IPolicyBuilder - { - private readonly AccessPolicyRegistry _registry; - private readonly IServiceProvider _services; + private readonly AccessPolicyRegistry _registry; + private readonly IServiceProvider _services; - public PolicyBuilder(AccessPolicyRegistry registry, IServiceProvider services) - { - _registry = registry; - _services = services; - } + public PolicyBuilder(AccessPolicyRegistry registry, IServiceProvider services) + { + _registry = registry; + _services = services; + } - public IPolicyScopeBuilder For(string actionPrefix) => new PolicyScopeBuilder(actionPrefix, _registry, _services); + public IPolicyScopeBuilder For(string actionPrefix) => new PolicyScopeBuilder(actionPrefix, _registry, _services); - public IPolicyScopeBuilder Global() => new PolicyScopeBuilder(string.Empty, _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 index 61c73fb6..61adab98 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs @@ -3,51 +3,35 @@ 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 RequireAdmin() - => Add(); - - public IPolicyScopeBuilder RequireSelfOrAdmin() - => Add(); +namespace CodeBeam.UltimateAuth.Policies; - public IPolicyScopeBuilder DenyCrossTenant() - => Add(); - - private IPolicyScopeBuilder Add() - where TPolicy : IAccessPolicy - { - _registry.Add(_prefix, sp => ActivatorUtilities.CreateInstance(sp)); - return this; - } +internal sealed class PolicyScopeBuilder : IPolicyScopeBuilder +{ + private readonly string _prefix; + private readonly AccessPolicyRegistry _registry; + private readonly IServiceProvider _services; - public IConditionalPolicyBuilder When(Func predicate) - { - return new ConditionalPolicyBuilder(_prefix, predicate, _registry, _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 RequireAdmin() => Add(); + public IPolicyScopeBuilder RequireSelfOrAdmin() => 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 index 696c0f2c..eb7e33bc 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/ConditionalAccessPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/ConditionalAccessPolicy.cs @@ -1,23 +1,22 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Policies +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class ConditionalAccessPolicy : IAccessPolicy { - internal sealed class ConditionalAccessPolicy : IAccessPolicy - { - private readonly Func _condition; - private readonly bool _expected; - private readonly IAccessPolicy _inner; + 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 ConditionalAccessPolicy(Func condition, bool expected, IAccessPolicy inner) + { + _condition = condition; + _expected = expected; + _inner = inner; + } - public bool AppliesTo(AccessContext context) => _condition(context) == _expected; + public bool AppliesTo(AccessContext context) => _condition(context) == _expected; - public AccessDecision Decide(AccessContext context) => _inner.Decide(context); - } + public AccessDecision Decide(AccessContext context) => _inner.Decide(context); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs index 1fc8f4be..fa9bc520 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs @@ -1,46 +1,45 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Policies +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class RequireActiveUserPolicy : IAccessPolicy { - 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) { - 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.ActorTenantId, 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; - - return !AllowedForInactive.Any(prefix => context.Action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); - } - - private static readonly string[] AllowedForInactive = - { - "users.status.change.", - "credentials.password.reset.", - "login.", - "reauth." - }; + if (!context.IsAuthenticated || context.IsSystemActor) + return false; + + return !AllowedForInactive.Any(prefix => context.Action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); } + + private static readonly string[] AllowedForInactive = + { + "users.status.change.", + "credentials.password.reset.", + "login.", + "reauth." + }; } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs index 0524b71f..8f2740ab 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs @@ -1,15 +1,14 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Policies +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class RequireSystemPolicy : IAccessPolicy { - internal sealed class RequireSystemPolicy : IAccessPolicy - { - public AccessDecision Decide(AccessContext context) - => context.IsSystemActor - ? AccessDecision.Allow() - : AccessDecision.Deny("system_actor_required"); + 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); - } + public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".system", StringComparison.Ordinal); } diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs index fc8c324d..4f73b643 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs @@ -1,13 +1,12 @@ -namespace CodeBeam.UltimateAuth.Security.Argon2 +namespace CodeBeam.UltimateAuth.Security.Argon2; + +public sealed class Argon2Options { - 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; + // 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; - } + 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 index 6ddf7d4a..c0d2dca4 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -3,60 +3,59 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Konscious.Security.Cryptography; -namespace CodeBeam.UltimateAuth.Security.Argon2 +namespace CodeBeam.UltimateAuth.Security.Argon2; + +public sealed class Argon2PasswordHasher : IUAuthPasswordHasher { - public sealed class Argon2PasswordHasher : IUAuthPasswordHasher - { - private readonly Argon2Options _options; + private readonly Argon2Options _options; - public Argon2PasswordHasher(Argon2Options options) - { - _options = options; - } + public Argon2PasswordHasher(Argon2Options options) + { + _options = options; + } - public string Hash(string password) - { - if (string.IsNullOrEmpty(password)) - throw new ArgumentException("Password cannot be null or empty.", nameof(password)); + public string Hash(string password) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentException("Password cannot be null or empty.", nameof(password)); - var salt = RandomNumberGenerator.GetBytes(_options.SaltSize); + var salt = RandomNumberGenerator.GetBytes(_options.SaltSize); - var argon2 = CreateArgon2(password, salt); + var argon2 = CreateArgon2(password, salt); - var hash = argon2.GetBytes(_options.HashSize); + var hash = argon2.GetBytes(_options.HashSize); - // format: - // {salt}.{hash} - return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; - } + // 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; + 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; + var parts = hash.Split('.'); + if (parts.Length != 2) + return false; - var salt = Convert.FromBase64String(parts[0]); - var expectedHash = Convert.FromBase64String(parts[1]); + var salt = Convert.FromBase64String(parts[0]); + var expectedHash = Convert.FromBase64String(parts[1]); - var argon2 = CreateArgon2(secret, salt); - var actualHash = argon2.GetBytes(expectedHash.Length); + var argon2 = CreateArgon2(secret, salt); + var actualHash = argon2.GetBytes(expectedHash.Length); - return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); - } + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + } - private Argon2id CreateArgon2(string password, byte[] salt) + private Argon2id CreateArgon2(string password, byte[] salt) + { + return new Argon2id(Encoding.UTF8.GetBytes(password)) { - return new Argon2id(Encoding.UTF8.GetBytes(password)) - { - Salt = salt, - DegreeOfParallelism = _options.Parallelism, - Iterations = _options.Iterations, - MemorySize = _options.MemorySizeKb - }; - } + Salt = salt, + DegreeOfParallelism = _options.Parallelism, + Iterations = _options.Iterations, + MemorySize = _options.MemorySizeKb + }; } } 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 index a3e1cf08..f29fc2e2 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj @@ -6,6 +6,7 @@ enable 0.0.1-preview true + $(NoWarn);1591 diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs index 12593d48..2227e6a9 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs @@ -1,19 +1,18 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Security.Argon2 +namespace CodeBeam.UltimateAuth.Security.Argon2; + +public static class ServiceCollectionExtensions { - public static class ServiceCollectionExtensions + public static IServiceCollection AddUltimateAuthArgon2(this IServiceCollection services, Action? configure = null) { - public static IServiceCollection AddUltimateAuthArgon2(this IServiceCollection services, Action? configure = null) - { - var options = new Argon2Options(); - configure?.Invoke(options); + var options = new Argon2Options(); + configure?.Invoke(options); - services.AddSingleton(options); - services.AddSingleton(); + services.AddSingleton(options); + services.AddSingleton(); - return services; - } + return services; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs deleted file mode 100644 index 75245e2a..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AuthSessionIdEfConverter.cs +++ /dev/null @@ -1,38 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Sessions.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/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs new file mode 100644 index 00000000..16666c7b --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -0,0 +1,122 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class UltimateAuthSessionDbContext : DbContext +{ + public DbSet Roots => Set(); + public DbSet Chains => Set(); + public DbSet Sessions => Set(); + + + private readonly TenantContext _tenant; + + public UltimateAuthSessionDbContext(DbContextOptions options, TenantContext tenant) : base(options) + { + _tenant = tenant; + } + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.UserKey) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }) + .IsUnique(); + + e.Property(x => x.SecurityVersion) + .IsRequired(); + + e.Property(x => x.LastUpdatedAt) + .IsRequired(); + + e.Property(x => x.RootId) + .HasConversion( + v => v.Value, + v => SessionRootId.From(v)) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.RootId }); + + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.UserKey) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.ChainId }).IsUnique(); + + e.Property(x => x.ChainId) + .HasConversion( + v => v.Value, + v => SessionChainId.From(v)) + .IsRequired(); + + e.Property(x => x.ActiveSessionId) + .HasConversion(new NullableAuthSessionIdConverter()); + + e.Property(x => x.ClaimsSnapshot) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.SecurityVersionAtCreation) + .IsRequired(); + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.RowVersion).IsRowVersion(); + + e.HasIndex(x => new { x.Tenant, x.SessionId }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.ChainId, x.RevokedAt }); + + 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(); + }); + } + +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs deleted file mode 100644 index 59a52927..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs +++ /dev/null @@ -1,376 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore; -using System.Security; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; - -internal sealed class EfCoreSessionStore : ISessionStore -{ - private readonly EfCoreSessionStoreKernel _kernel; - private readonly UltimateAuthSessionDbContext _db; - - public EfCoreSessionStore(EfCoreSessionStoreKernel kernel, UltimateAuthSessionDbContext db) - { - _kernel = kernel; - _db = db; - } - - public async Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) - { - var projection = await _db.Sessions - .AsNoTracking() - .SingleOrDefaultAsync( - x => x.SessionId == sessionId && - x.TenantId == tenantId, - ct); - - return projection?.ToDomain(); - } - - public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) - { - await _kernel.ExecuteAsync(async ct => - { - var now = ctx.IssuedAt; - - var rootProjection = await _db.Roots - .SingleOrDefaultAsync( - x => x.TenantId == ctx.TenantId && - x.UserKey == ctx.UserKey, - ct); - - ISessionRoot root; - - if (rootProjection is null) - { - root = UAuthSessionRoot.Create(ctx.TenantId, ctx.UserKey, now); - _db.Roots.Add(root.ToProjection()); - } - else - { - var chainProjections = await _db.Chains - .AsNoTracking() - .Where(x => x.RootId == rootProjection.RootId) - .ToListAsync(ct); - - root = rootProjection.ToDomain( - chainProjections.Select(c => c.ToDomain()).ToList()); - } - - ISessionChain chain; - - if (ctx.ChainId is not null) - { - var chainProjection = await _db.Chains - .SingleAsync(x => x.ChainId == ctx.ChainId.Value, ct); - - chain = chainProjection.ToDomain(); - } - else - { - chain = UAuthSessionChain.Create( - SessionChainId.New(), - root.RootId, - ctx.TenantId, - ctx.UserKey, - root.SecurityVersion, - ClaimsSnapshot.Empty); - - _db.Chains.Add(chain.ToProjection()); - root = root.AttachChain(chain, now); - } - - var issuedSession = (UAuthSession)issued.Session; - - if (!issuedSession.ChainId.IsUnassigned) - throw new InvalidOperationException("Issued session already has chain."); - - var session = issuedSession.WithChain(chain.ChainId); - - _db.Sessions.Add(session.ToProjection()); - - var updatedChain = chain.AttachSession(session.SessionId); - _db.Chains.Update(updatedChain.ToProjection()); - }, ct); - } - - public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) - { - await _kernel.ExecuteAsync(async ct => - { - var now = ctx.IssuedAt; - - var oldProjection = await _db.Sessions - .SingleOrDefaultAsync( - x => x.SessionId == currentSessionId && - x.TenantId == ctx.TenantId, - ct); - - if (oldProjection is null) - throw new SecurityException("Session not found."); - - var oldSession = oldProjection.ToDomain(); - - if (oldSession.IsRevoked || oldSession.ExpiresAt <= now) - throw new SecurityException("Session is no longer valid."); - - var chainProjection = await _db.Chains - .SingleOrDefaultAsync( - x => x.ChainId == oldSession.ChainId, - ct); - - if (chainProjection is null) - throw new SecurityException("Chain not found."); - - var chain = chainProjection.ToDomain(); - - if (chain.IsRevoked) - throw new SecurityException("Chain is revoked."); - - var newSession = ((UAuthSession)issued.Session) - .WithChain(chain.ChainId); - - _db.Sessions.Add(newSession.ToProjection()); - - var rotatedChain = chain.RotateSession(newSession.SessionId); - _db.Chains.Update(rotatedChain.ToProjection()); - - var revokedOld = oldSession.Revoke(now); - _db.Sessions.Update(revokedOld.ToProjection()); - }, ct); - } - - public async Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default) - { - var touched = false; - - await _kernel.ExecuteAsync(async ct => - { - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); - - if (projection is null) - return; - - var session = projection.ToDomain(); - - if (session.IsRevoked) - return; - - if (mode == SessionTouchMode.IfNeeded && at - session.LastSeenAt < TimeSpan.FromMinutes(1)) - return; - - var updated = session.Touch(at); - _db.Sessions.Update(updated.ToProjection()); - - touched = true; - }, ct); - - return touched; - } - - public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) - => _kernel.ExecuteAsync(async ct => - { - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId && x.TenantId == tenantId, ct); - - if (projection is null) - return; - - var session = projection.ToDomain(); - - if (session.IsRevoked) - return; - - _db.Sessions.Update(session.Revoke(at).ToProjection()); - }, ct); - - public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) - { - await _kernel.ExecuteAsync(async ct => - { - var chains = await _db.Chains - .Where(x => - x.TenantId == tenantId && - x.UserKey == userKey) - .ToListAsync(ct); - - foreach (var chainProjection in chains) - { - if (exceptChainId.HasValue && - chainProjection.ChainId == exceptChainId.Value) - continue; - - var chain = chainProjection.ToDomain(); - - if (!chain.IsRevoked) - _db.Chains.Update(chain.Revoke(at).ToProjection()); - - if (chain.ActiveSessionId is not null) - { - var sessionProjection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); - - if (sessionProjection is not null) - { - var session = sessionProjection.ToDomain(); - if (!session.IsRevoked) - _db.Sessions.Update(session.Revoke(at).ToProjection()); - } - } - } - }, ct); - } - - public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) - => _kernel.ExecuteAsync(async ct => - { - var projection = await _db.Chains - .SingleOrDefaultAsync( - x => x.ChainId == chainId && - x.TenantId == tenantId, - ct); - - if (projection is null) - return; - - var chain = projection.ToDomain(); - - if (chain.IsRevoked) - return; - - _db.Chains.Update(chain.Revoke(at).ToProjection()); - - if (chain.ActiveSessionId is not null) - { - var sessionProjection = await _db.Sessions - .SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); - - if (sessionProjection is not null) - { - var session = sessionProjection.ToDomain(); - if (!session.IsRevoked) - _db.Sessions.Update(session.Revoke(at).ToProjection()); - } - } - }, ct); - - public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) - => _kernel.ExecuteAsync(async ct => - { - var rootProjection = await _db.Roots - .SingleOrDefaultAsync( - x => x.TenantId == tenantId && - x.UserKey == userKey, - ct); - - if (rootProjection is null) - return; - - var chainProjections = await _db.Chains - .Where(x => x.RootId == rootProjection.RootId) - .ToListAsync(ct); - - foreach (var chainProjection in chainProjections) - { - var chain = chainProjection.ToDomain(); - _db.Chains.Update(chain.Revoke(at).ToProjection()); - - if (chain.ActiveSessionId is not null) - { - var sessionProjection = await _db.Sessions - .SingleOrDefaultAsync(x => x.SessionId == chain.ActiveSessionId, ct); - - if (sessionProjection is not null) - { - var session = sessionProjection.ToDomain(); - _db.Sessions.Update(session.Revoke(at).ToProjection()); - } - } - } - - var root = rootProjection.ToDomain(chainProjections.Select(c => c.ToDomain()).ToList()); - - _db.Roots.Update(root.Revoke(at).ToProjection()); - }, ct); - - public async Task> GetSessionsByChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) - { - var projections = await _db.Sessions - .AsNoTracking() - .Where(x => - x.ChainId == chainId && - x.TenantId == tenantId) - .ToListAsync(ct); - - return projections.Select(x => x.ToDomain()).ToList(); - } - - public async Task> GetChainsByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - var projections = await _db.Chains - .AsNoTracking() - .Where(x => - x.TenantId == tenantId && - x.UserKey.Equals(userKey)) - .ToListAsync(ct); - - return projections.Select(x => x.ToDomain()).ToList(); - } - - public async Task GetChainAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) - { - var projection = await _db.Chains - .AsNoTracking() - .SingleOrDefaultAsync( - x => x.ChainId == chainId && - x.TenantId == tenantId, - ct); - - return projection?.ToDomain(); - } - - public async Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) - { - return await _db.Sessions - .AsNoTracking() - .Where(x => - x.SessionId == sessionId && - x.TenantId == tenantId) - .Select(x => (SessionChainId?)x.ChainId) - .SingleOrDefaultAsync(ct); - } - - public async Task GetActiveSessionIdAsync(string? tenantId, SessionChainId chainId, CancellationToken ct = default) - { - return await _db.Chains - .AsNoTracking() - .Where(x => - x.ChainId == chainId && - x.TenantId == tenantId) - .Select(x => x.ActiveSessionId) - .SingleOrDefaultAsync(ct); - } - - public async Task GetSessionRootAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync( - x => x.TenantId == tenantId && - x.UserKey!.Equals(userKey), - ct); - - if (rootProjection is null) - return null; - - var chainProjections = await _db.Chains - .AsNoTracking() - .Where(x => - x.TenantId == tenantId && - x.UserKey!.Equals(userKey)) - .ToListAsync(ct); - - return rootProjection.ToDomain(chainProjections.Select(x => x.ToDomain()).ToList()); - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs deleted file mode 100644 index 05b51217..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs +++ /dev/null @@ -1,246 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore; -using System.Data; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore -{ - internal sealed class EfCoreSessionStoreKernel : ISessionStoreKernel - { - private readonly UltimateAuthSessionDbContext _db; - - public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db) - { - _db = db; - } - - public async Task ExecuteAsync(Func action, CancellationToken ct = default) - { - var strategy = _db.Database.CreateExecutionStrategy(); - - await strategy.ExecuteAsync(async () => - { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); - - try - { - await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - finally - { - _db.Database.UseTransaction(null); - } - }); - } - - public async Task GetSessionAsync(AuthSessionId sessionId) - { - var projection = await _db.Sessions - .AsNoTracking() - .SingleOrDefaultAsync(x => x.SessionId == sessionId); - - return projection?.ToDomain(); - } - - public async Task SaveSessionAsync(ISession session) - { - var projection = session.ToProjection(); - - var exists = await _db.Sessions - .AnyAsync(x => x.SessionId == session.SessionId); - - if (exists) - _db.Sessions.Update(projection); - else - _db.Sessions.Add(projection); - } - - public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) - { - var projection = await _db.Sessions - .SingleOrDefaultAsync(x => x.SessionId == sessionId); - - if (projection is null) - return; - - var session = projection.ToDomain(); - if (session.IsRevoked) - return; - - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } - - public async Task GetChainAsync(SessionChainId chainId) - { - var projection = await _db.Chains - .AsNoTracking() - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - return projection?.ToDomain(); - } - - public async Task SaveChainAsync(ISessionChain chain) - { - var projection = chain.ToProjection(); - - var exists = await _db.Chains - .AnyAsync(x => x.ChainId == chain.ChainId); - - if (exists) - _db.Chains.Update(projection); - else - _db.Chains.Add(projection); - } - - public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) - { - var projection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - if (projection is null) - return; - - var chain = projection.ToDomain(); - if (chain.IsRevoked) - return; - - _db.Chains.Update(chain.Revoke(at).ToProjection()); - } - - public async Task GetActiveSessionIdAsync(SessionChainId chainId) - { - return await _db.Chains - .AsNoTracking() - .Where(x => x.ChainId == chainId) - .Select(x => x.ActiveSessionId) - .SingleOrDefaultAsync(); - } - - public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) - { - var projection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - if (projection is null) - return; - - projection.ActiveSessionId = sessionId; - _db.Chains.Update(projection); - } - - public async Task GetSessionRootByUserAsync(UserKey userKey) - { - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); - } - - public async Task SaveSessionRootAsync(ISessionRoot root) - { - var projection = root.ToProjection(); - - var exists = await _db.Roots - .AnyAsync(x => x.RootId == root.RootId); - - if (exists) - _db.Roots.Update(projection); - else - _db.Roots.Add(projection); - } - - public async Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) - { - var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (projection is null) - return; - - var root = projection.ToDomain(); - _db.Roots.Update(root.Revoke(at).ToProjection()); - } - - public async Task GetChainIdBySessionAsync(AuthSessionId sessionId) - { - return await _db.Sessions - .AsNoTracking() - .Where(x => x.SessionId == sessionId) - .Select(x => (SessionChainId?)x.ChainId) - .SingleOrDefaultAsync(); - } - - public async Task> GetChainsByUserAsync(UserKey userKey) - { - var projections = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return projections.Select(x => x.ToDomain()).ToList(); - } - - public async Task> GetSessionsByChainAsync(SessionChainId chainId) - { - var projections = await _db.Sessions - .AsNoTracking() - .Where(x => x.ChainId == chainId) - .ToListAsync(); - - return projections.Select(x => x.ToDomain()).ToList(); - } - - public async Task GetSessionRootByIdAsync(SessionRootId rootId) - { - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.RootId == rootId); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.RootId == rootId) - .ToListAsync(); - - return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); - } - - - public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) - { - var projections = await _db.Sessions - .Where(x => x.ExpiresAt <= at && !x.IsRevoked) - .ToListAsync(); - - foreach (var p in projections) - { - var revoked = p.ToDomain().Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } - } - - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs deleted file mode 100644 index 86770175..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernelFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore -{ - public sealed class EfCoreSessionStoreKernelFactory : ISessionStoreKernelFactory - { - private readonly IServiceProvider _sp; - - public EfCoreSessionStoreKernelFactory(IServiceProvider sp) - { - _sp = sp; - } - - public ISessionStoreKernel Create(string? tenantId) - { - return _sp.GetRequiredService(); - } - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index d0d5a59f..20358cf6 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -1,27 +1,27 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class SessionChainProjection { - internal sealed class SessionChainProjection - { - public long Id { get; set; } + public long Id { get; set; } - public SessionChainId ChainId { get; set; } = default!; - public SessionRootId RootId { get; } + public SessionChainId ChainId { get; set; } = default!; + public SessionRootId RootId { get; } - public string? TenantId { get; set; } - public UserKey UserKey { get; set; } + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } - public int RotationCount { get; set; } - public long SecurityVersionAtCreation { get; set; } + public int RotationCount { get; set; } + public long SecurityVersionAtCreation { get; set; } - public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; + public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; - public AuthSessionId? ActiveSessionId { get; set; } + public AuthSessionId? ActiveSessionId { get; set; } - public bool IsRevoked { get; set; } - public DateTimeOffset? RevokedAt { get; set; } + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; - } + public byte[] RowVersion { get; set; } = default!; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs index e2230497..fdf642ed 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -1,31 +1,30 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore -{ - internal sealed class SessionProjection - { - public long Id { get; set; } // EF internal PK +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; - public AuthSessionId SessionId { get; set; } = default!; - public SessionChainId ChainId { get; set; } = default!; +internal sealed class SessionProjection +{ + public long Id { get; set; } // EF internal PK - public string? TenantId { get; set; } - public UserKey UserKey { get; set; } = default!; + public AuthSessionId SessionId { get; set; } = default!; + public SessionChainId ChainId { get; set; } = default!; - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset ExpiresAt { get; set; } - public DateTimeOffset? LastSeenAt { get; set; } + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } = default!; - public bool IsRevoked { get; set; } - public DateTimeOffset? RevokedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? LastSeenAt { get; set; } - public long SecurityVersionAtCreation { get; set; } + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } - public DeviceContext Device { get; set; } - public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; - public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; + public long SecurityVersionAtCreation { get; set; } - public byte[] RowVersion { get; set; } = default!; - } + public DeviceContext Device { get; set; } = DeviceContext.Anonymous(); + public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; + public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; + public byte[] RowVersion { get; set; } = default!; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs index c49aae0f..05be286e 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -1,20 +1,20 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class SessionRootProjection { - internal sealed class SessionRootProjection - { - public long Id { get; set; } - public SessionRootId RootId { get; set; } - public string? TenantId { get; set; } - public UserKey UserKey { get; set; } + public long Id { get; set; } + public SessionRootId RootId { get; set; } + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } - public bool IsRevoked { get; set; } - public DateTimeOffset? RevokedAt { get; set; } + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } - public long SecurityVersion { get; set; } - public DateTimeOffset LastUpdatedAt { get; set; } + public long SecurityVersion { get; set; } + public DateTimeOffset LastUpdatedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; - } + public byte[] RowVersion { get; set; } = default!; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs similarity index 77% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 2b786a5c..6f0f3e80 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; @@ -10,7 +9,6 @@ public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(configureDb); services.AddScoped(); - services.AddScoped(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs new file mode 100644 index 00000000..60c6a5b1 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.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/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs new file mode 100644 index 00000000..68fb5ff1 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class JsonValueConverter : ValueConverter +{ + public JsonValueConverter() + : base( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!) + { + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs new file mode 100644 index 00000000..099a69cc --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class AuthSessionIdConverter : ValueConverter +{ + public AuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabase(id), raw => AuthSessionIdEfConverter.FromDatabase(raw)) + { + } +} + +internal sealed class NullableAuthSessionIdConverter : ValueConverter +{ + public NullableAuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabaseNullable(id), raw => AuthSessionIdEfConverter.FromDatabaseNullable(raw)) + { + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs deleted file mode 100644 index 0d5b86db..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System.Text.Json; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore -{ - internal sealed class JsonValueConverter : ValueConverter - { - public JsonValueConverter() - : base( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!) - { - } - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index b1b1c32e..93c582f4 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -1,43 +1,42 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal static class SessionChainProjectionMapper { - internal static class SessionChainProjectionMapper + public static UAuthSessionChain ToDomain(this SessionChainProjection p) { - public static ISessionChain ToDomain(this SessionChainProjection p) - { - return UAuthSessionChain.FromProjection( - p.ChainId, - p.RootId, - p.TenantId, - p.UserKey, - p.RotationCount, - p.SecurityVersionAtCreation, - p.ClaimsSnapshot, - p.ActiveSessionId, - p.IsRevoked, - p.RevokedAt - ); - } + return UAuthSessionChain.FromProjection( + p.ChainId, + p.RootId, + p.Tenant, + p.UserKey, + p.RotationCount, + p.SecurityVersionAtCreation, + p.ClaimsSnapshot, + p.ActiveSessionId, + p.IsRevoked, + p.RevokedAt + ); + } - public static SessionChainProjection ToProjection(this ISessionChain chain) + public static SessionChainProjection ToProjection(this UAuthSessionChain chain) + { + return new SessionChainProjection { - return new SessionChainProjection - { - ChainId = chain.ChainId, - TenantId = chain.TenantId, - UserKey = chain.UserKey, - - RotationCount = chain.RotationCount, - SecurityVersionAtCreation = chain.SecurityVersionAtCreation, - ClaimsSnapshot = chain.ClaimsSnapshot, + ChainId = chain.ChainId, + Tenant = chain.Tenant, + UserKey = chain.UserKey, - ActiveSessionId = chain.ActiveSessionId, + RotationCount = chain.RotationCount, + SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + ClaimsSnapshot = chain.ClaimsSnapshot, - IsRevoked = chain.IsRevoked, - RevokedAt = chain.RevokedAt - }; - } + ActiveSessionId = chain.ActiveSessionId, + IsRevoked = chain.IsRevoked, + RevokedAt = chain.RevokedAt + }; } + } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index ed2a371e..da2a7fe8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -1,50 +1,49 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal static class SessionProjectionMapper { - internal static class SessionProjectionMapper + public static UAuthSession ToDomain(this SessionProjection p) { - public static ISession ToDomain(this SessionProjection p) - { - return UAuthSession.FromProjection( - p.SessionId, - p.TenantId, - p.UserKey, - p.ChainId, - p.CreatedAt, - p.ExpiresAt, - p.LastSeenAt, - p.IsRevoked, - p.RevokedAt, - p.SecurityVersionAtCreation, - p.Device, - p.Claims, - p.Metadata - ); - } + return UAuthSession.FromProjection( + p.SessionId, + p.Tenant, + p.UserKey, + p.ChainId, + p.CreatedAt, + p.ExpiresAt, + p.LastSeenAt, + p.IsRevoked, + p.RevokedAt, + p.SecurityVersionAtCreation, + p.Device, + p.Claims, + p.Metadata + ); + } - public static SessionProjection ToProjection(this ISession s) + public static SessionProjection ToProjection(this UAuthSession s) + { + return new SessionProjection { - return new SessionProjection - { - SessionId = s.SessionId, - TenantId = s.TenantId, - UserKey = s.UserKey, - ChainId = s.ChainId, - - CreatedAt = s.CreatedAt, - ExpiresAt = s.ExpiresAt, - LastSeenAt = s.LastSeenAt, + SessionId = s.SessionId, + Tenant = s.Tenant, + UserKey = s.UserKey, + ChainId = s.ChainId, - IsRevoked = s.IsRevoked, - RevokedAt = s.RevokedAt, + CreatedAt = s.CreatedAt, + ExpiresAt = s.ExpiresAt, + LastSeenAt = s.LastSeenAt, - SecurityVersionAtCreation = s.SecurityVersionAtCreation, - Device = s.Device, - Claims = s.Claims, - Metadata = s.Metadata - }; - } + IsRevoked = s.IsRevoked, + RevokedAt = s.RevokedAt, + SecurityVersionAtCreation = s.SecurityVersionAtCreation, + Device = s.Device, + Claims = s.Claims, + Metadata = s.Metadata + }; } + } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index e8f0f950..a0c223ae 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -1,38 +1,36 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal static class SessionRootProjectionMapper { - internal static class SessionRootProjectionMapper + public static UAuthSessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList? chains = null) { - public static ISessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList? chains = null) - { - return UAuthSessionRoot.FromProjection( - root.RootId, - root.TenantId, - root.UserKey, - root.IsRevoked, - root.RevokedAt, - root.SecurityVersion, - chains ?? Array.Empty(), - root.LastUpdatedAt - ); - } + return UAuthSessionRoot.FromProjection( + root.RootId, + root.Tenant, + root.UserKey, + root.IsRevoked, + root.RevokedAt, + root.SecurityVersion, + chains ?? Array.Empty(), + root.LastUpdatedAt + ); + } - public static SessionRootProjection ToProjection(this ISessionRoot root) + public static SessionRootProjection ToProjection(this UAuthSessionRoot root) + { + return new SessionRootProjection { - return new SessionRootProjection - { - RootId = root.RootId, - TenantId = root.TenantId, - UserKey = root.UserKey, - - IsRevoked = root.IsRevoked, - RevokedAt = root.RevokedAt, + RootId = root.RootId, + Tenant = root.Tenant, + UserKey = root.UserKey, - SecurityVersion = root.SecurityVersion, - LastUpdatedAt = root.LastUpdatedAt - }; - } + IsRevoked = root.IsRevoked, + RevokedAt = root.RevokedAt, + SecurityVersion = root.SecurityVersion, + LastUpdatedAt = root.LastUpdatedAt + }; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs deleted file mode 100644 index 545bce45..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore -{ - internal sealed class AuthSessionIdConverter : ValueConverter - { - public AuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabase(id), raw => AuthSessionIdEfConverter.FromDatabase(raw)) - { - } - } - - internal sealed class NullableAuthSessionIdConverter : ValueConverter - { - public NullableAuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabaseNullable(id), raw => AuthSessionIdEfConverter.FromDatabaseNullable(raw)) - { - } - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs new file mode 100644 index 00000000..eb7616ba --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs @@ -0,0 +1,248 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class EfCoreSessionStoreKernel : ISessionStoreKernel +{ + private readonly UltimateAuthSessionDbContext _db; + private readonly TenantContext _tenant; + + public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + + public async Task GetSessionAsync(AuthSessionId sessionId) + { + var projection = await _db.Sessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.SessionId == sessionId); + + return projection?.ToDomain(); + } + + public async Task SaveSessionAsync(UAuthSession session) + { + var projection = session.ToProjection(); + + var exists = await _db.Sessions + .AnyAsync(x => x.SessionId == session.SessionId); + + if (exists) + _db.Sessions.Update(projection); + else + _db.Sessions.Add(projection); + } + + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + { + var projection = await _db.Sessions + .SingleOrDefaultAsync(x => x.SessionId == sessionId); + + if (projection is null) + return; + + var session = projection.ToDomain(); + if (session.IsRevoked) + return; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + public async Task GetChainAsync(SessionChainId chainId) + { + var projection = await _db.Chains + .AsNoTracking() + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + return projection?.ToDomain(); + } + + public async Task SaveChainAsync(UAuthSessionChain chain) + { + var projection = chain.ToProjection(); + + var exists = await _db.Chains + .AnyAsync(x => x.ChainId == chain.ChainId); + + if (exists) + _db.Chains.Update(projection); + else + _db.Chains.Add(projection); + } + + public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) + { + var projection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + var chain = projection.ToDomain(); + if (chain.IsRevoked) + return; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + + public async Task GetActiveSessionIdAsync(SessionChainId chainId) + { + return await _db.Chains + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .Select(x => x.ActiveSessionId) + .SingleOrDefaultAsync(); + } + + public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) + { + var projection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + projection.ActiveSessionId = sessionId; + _db.Chains.Update(projection); + } + + public async Task GetSessionRootByUserAsync(UserKey userKey) + { + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.UserKey == userKey) + .ToListAsync(); + + return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); + } + + public async Task SaveSessionRootAsync(UAuthSessionRoot root) + { + var projection = root.ToProjection(); + + var exists = await _db.Roots + .AnyAsync(x => x.RootId == root.RootId); + + if (exists) + _db.Roots.Update(projection); + else + _db.Roots.Add(projection); + } + + public async Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) + { + var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (projection is null) + return; + + var root = projection.ToDomain(); + _db.Roots.Update(root.Revoke(at).ToProjection()); + } + + public async Task GetChainIdBySessionAsync(AuthSessionId sessionId) + { + return await _db.Sessions + .AsNoTracking() + .Where(x => x.SessionId == sessionId) + .Select(x => (SessionChainId?)x.ChainId) + .SingleOrDefaultAsync(); + } + + public async Task> GetChainsByUserAsync(UserKey userKey) + { + var projections = await _db.Chains + .AsNoTracking() + .Where(x => x.UserKey == userKey) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task> GetSessionsByChainAsync(SessionChainId chainId) + { + var projections = await _db.Sessions + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task GetSessionRootByIdAsync(SessionRootId rootId) + { + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.RootId == rootId); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.RootId == rootId) + .ToListAsync(); + + return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); + } + + + public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) + { + var projections = await _db.Sessions + .Where(x => x.ExpiresAt <= at && !x.IsRevoked) + .ToListAsync(); + + foreach (var p in projections) + { + var revoked = p.ToDomain().Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + } + +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs new file mode 100644 index 00000000..240b9b9d --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +public sealed class EfCoreSessionStoreKernelFactory : ISessionStoreKernelFactory +{ + private readonly IServiceProvider _sp; + + public EfCoreSessionStoreKernelFactory(IServiceProvider sp) + { + _sp = sp; + } + + public ISessionStoreKernel Create(TenantKey tenant) + { + return ActivatorUtilities.CreateInstance(_sp, new TenantContext(tenant)); + } + + // TODO: Implement global here + //public ISessionStoreKernel CreateGlobal() + //{ + // return ActivatorUtilities.CreateInstance(_sp, new TenantContext(null, isGlobal: true)); + //} +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs deleted file mode 100644 index 6e1b3f55..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs +++ /dev/null @@ -1,110 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.EntityFrameworkCore; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore -{ - internal sealed class UltimateAuthSessionDbContext : DbContext - { - public DbSet Roots => Set(); - public DbSet Chains => Set(); - public DbSet Sessions => Set(); - - public UltimateAuthSessionDbContext(DbContextOptions options) : base(options) - { - - } - - protected override void OnModelCreating(ModelBuilder b) - { - b.Entity(e => - { - e.HasKey(x => x.Id); - - e.Property(x => x.RowVersion) - .IsRowVersion(); - - e.Property(x => x.UserKey) - .IsRequired(); - - e.HasIndex(x => new { x.TenantId, x.UserKey }) - .IsUnique(); - - e.Property(x => x.SecurityVersion) - .IsRequired(); - - e.Property(x => x.LastUpdatedAt) - .IsRequired(); - - e.Property(x => x.RootId) - .HasConversion( - v => v.Value, - v => SessionRootId.From(v)) - .IsRequired(); - - e.HasIndex(x => new { x.TenantId, x.RootId }); - - }); - - b.Entity(e => - { - e.HasKey(x => x.Id); - - e.Property(x => x.RowVersion) - .IsRowVersion(); - - e.Property(x => x.UserKey) - .IsRequired(); - - e.HasIndex(x => new { x.TenantId, x.ChainId }).IsUnique(); - - e.Property(x => x.ChainId) - .HasConversion( - v => v.Value, - v => SessionChainId.From(v)) - .IsRequired(); - - e.Property(x => x.ActiveSessionId) - .HasConversion(new NullableAuthSessionIdConverter()); - - e.Property(x => x.ClaimsSnapshot) - .HasConversion(new JsonValueConverter()) - .IsRequired(); - - e.Property(x => x.SecurityVersionAtCreation) - .IsRequired(); - }); - - b.Entity(e => - { - e.HasKey(x => x.Id); - e.Property(x => x.RowVersion).IsRowVersion(); - - e.HasIndex(x => new { x.TenantId, x.SessionId }).IsUnique(); - e.HasIndex(x => new { x.TenantId, x.ChainId, x.RevokedAt }); - - 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(); - }); - } - - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs deleted file mode 100644 index ed5f958f..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ /dev/null @@ -1,154 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; -using System.Security; - -public sealed class InMemorySessionStore : ISessionStore -{ - private readonly ISessionStoreKernelFactory _factory; - private readonly UAuthServerOptions _options; - - public InMemorySessionStore(ISessionStoreKernelFactory factory, IOptions options) - { - _factory = factory; - _options = options.Value; - } - - private ISessionStoreKernel Kernel(string? tenantId) - => _factory.Create(tenantId); - - public Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) - => Kernel(tenantId).GetSessionAsync(sessionId); - - public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) - { - var k = Kernel(ctx.TenantId); - - await k.ExecuteAsync(async (ct) => - { - var now = ctx.IssuedAt; - - var root = await k.GetSessionRootByUserAsync(ctx.UserKey) ?? UAuthSessionRoot.Create(ctx.TenantId, ctx.UserKey, now); - ISessionChain chain; - - if (ctx.ChainId is not null) - { - chain = await k.GetChainAsync(ctx.ChainId.Value) ?? throw new InvalidOperationException("Chain not found."); - } - else - { - chain = UAuthSessionChain.Create( - SessionChainId.New(), - root.RootId, - ctx.TenantId, - ctx.UserKey, - root.SecurityVersion, - ClaimsSnapshot.Empty); - - root = root.AttachChain(chain, now); - } - - var session = issued.Session; - - if (!session.ChainId.IsUnassigned) - { - throw new InvalidOperationException("Issued session already has a chain assigned."); - } - - session = session.WithChain(chain.ChainId); - - // Persist (order intentional) - await k.SaveSessionRootAsync(root); - await k.SaveChainAsync(chain); - await k.SaveSessionAsync(session); - await k.SetActiveSessionIdAsync(chain.ChainId, session.SessionId); - }, ct); - } - - public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) - { - var k = Kernel(ctx.TenantId); - - await k.ExecuteAsync(async (ct) => - { - var now = ctx.IssuedAt; - - var old = await k.GetSessionAsync(currentSessionId) - ?? throw new SecurityException("Session not found."); - - if (old.IsRevoked || old.ExpiresAt <= now) - throw new SecurityException("Session is no longer valid."); - - var chain = await k.GetChainAsync(old.ChainId) - ?? throw new SecurityException("Chain not found."); - - if (chain.IsRevoked) - throw new SecurityException("Chain is revoked."); - - var newSession = ((UAuthSession)issued.Session).WithChain(chain.ChainId); - - await k.SaveSessionAsync(newSession); - await k.SetActiveSessionIdAsync(chain.ChainId, newSession.SessionId); - await k.RevokeSessionAsync(old.SessionId, now); - }, ct); - } - - public async Task TouchSessionAsync(AuthSessionId sessionId, DateTimeOffset at, SessionTouchMode mode = SessionTouchMode.IfNeeded, CancellationToken ct = default) - { - var k = Kernel(null); - bool touched = false; - - await k.ExecuteAsync(async (ct) => - { - var session = await k.GetSessionAsync(sessionId); - if (session is null || session.IsRevoked) - return; - - if (mode == SessionTouchMode.IfNeeded) - { - var elapsed = at - session.LastSeenAt; - if (elapsed < _options.Session.TouchInterval) - return; - } - - var updated = session.Touch(at); - await k.SaveSessionAsync(updated); - - touched = true; - }, ct); - - return touched; - } - - public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) - => Kernel(tenantId).RevokeSessionAsync(sessionId, at); - - public async Task RevokeAllChainsAsync(string? tenantId, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) - { - var k = Kernel(tenantId); - - await k.ExecuteAsync(async (ct) => - { - var chains = await k.GetChainsByUserAsync(userKey); - - foreach (var chain in chains) - { - if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) - continue; - - await k.RevokeChainAsync(chain.ChainId, at); - - if (chain.ActiveSessionId is not null) - await k.RevokeSessionAsync(chain.ActiveSessionId.Value, at); - } - }, ct); - } - - public Task RevokeChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) - => Kernel(tenantId).RevokeChainAsync(chainId, at); - - public Task RevokeRootAsync(string? tenantId, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) - => Kernel(tenantId).RevokeSessionRootAsync(userKey, at); -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs deleted file mode 100644 index 157bfd8f..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using System.Collections.Concurrent; - -namespace CodeBeam.UltimateAuth.Sessions.InMemory -{ - public sealed class InMemorySessionStoreFactory : ISessionStoreKernelFactory - { - private readonly ConcurrentDictionary _stores = new(); - - public ISessionStoreKernel Create(string? tenantId) - { - var key = tenantId ?? "__single__"; - - var store = _stores.GetOrAdd(key, _ => - { - var k = new InMemorySessionStoreKernel(); - k.BindTenant(tenantId); - return k; - }); - - return (ISessionStoreKernel)store; - } - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs index 3aaeee4c..0b3cbb16 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs @@ -2,22 +2,17 @@ using CodeBeam.UltimateAuth.Core.Domain; using System.Collections.Concurrent; -internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel, ITenantAwareSessionStore +namespace CodeBeam.UltimateAuth.Sessions.InMemory; + +internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel { private readonly SemaphoreSlim _tx = new(1, 1); - private readonly ConcurrentDictionary _sessions = new(); - private readonly ConcurrentDictionary _chains = new(); - private readonly ConcurrentDictionary _roots = new(); + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _chains = new(); + private readonly ConcurrentDictionary _roots = new(); private readonly ConcurrentDictionary _activeSessions = new(); - public string? TenantId { get; private set; } - - public void BindTenant(string? tenantId) - { - TenantId = tenantId ?? "__single__"; - } - public async Task ExecuteAsync(Func action, CancellationToken ct = default) { await _tx.WaitAsync(ct); @@ -31,10 +26,9 @@ public async Task ExecuteAsync(Func action, Cancellatio } } - public Task GetSessionAsync(AuthSessionId sessionId) - => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); + public Task GetSessionAsync(AuthSessionId sessionId) => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); - public Task SaveSessionAsync(ISession session) + public Task SaveSessionAsync(UAuthSession session) { _sessions[session.SessionId] = session; return Task.CompletedTask; @@ -49,10 +43,10 @@ public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) return Task.CompletedTask; } - public Task GetChainAsync(SessionChainId chainId) + public Task GetChainAsync(SessionChainId chainId) => Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); - public Task SaveChainAsync(ISessionChain chain) + public Task SaveChainAsync(UAuthSessionChain chain) { _chains[chain.ChainId] = chain; return Task.CompletedTask; @@ -76,13 +70,13 @@ public Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessio return Task.CompletedTask; } - public Task GetSessionRootByUserAsync(UserKey userKey) + public Task GetSessionRootByUserAsync(UserKey userKey) => Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); - public Task GetSessionRootByIdAsync(SessionRootId rootId) + public Task GetSessionRootByIdAsync(SessionRootId rootId) => Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); - public Task SaveSessionRootAsync(ISessionRoot root) + public Task SaveSessionRootAsync(UAuthSessionRoot root) { _roots[root.UserKey] = root; return Task.CompletedTask; @@ -105,21 +99,21 @@ public Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) return Task.FromResult(null); } - public Task> GetChainsByUserAsync(UserKey userKey) + public Task> GetChainsByUserAsync(UserKey userKey) { if (!_roots.TryGetValue(userKey, out var root)) - return Task.FromResult>(Array.Empty()); + return Task.FromResult>(Array.Empty()); - return Task.FromResult>(root.Chains.ToList()); + return Task.FromResult>(root.Chains.ToList()); } - public Task> GetSessionsByChainAsync(SessionChainId chainId) + public Task> GetSessionsByChainAsync(SessionChainId chainId) { var result = _sessions.Values .Where(s => s.ChainId == chainId) .ToList(); - return Task.FromResult>(result); + return Task.FromResult>(result); } public Task DeleteExpiredSessionsAsync(DateTimeOffset at) @@ -130,7 +124,13 @@ public Task DeleteExpiredSessionsAsync(DateTimeOffset at) if (session.ExpiresAt <= at) { - _sessions[kvp.Key] = session.Revoke(at); + var revoked = session.Revoke(at); + _sessions[kvp.Key] = revoked; + + if (_activeSessions.TryGetValue(revoked.ChainId, out var activeId) && activeId == revoked.SessionId) + { + _activeSessions.TryRemove(revoked.ChainId, out _); + } } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs new file mode 100644 index 00000000..6bd845eb --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.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 InMemorySessionStoreKernelFactory : ISessionStoreKernelFactory +{ + private readonly ConcurrentDictionary _kernels = new(); + + public ISessionStoreKernel Create(TenantKey tenant) + { + return _kernels.GetOrAdd(tenant, _ => new InMemorySessionStoreKernel()); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs index c12a8157..aebffb25 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -1,16 +1,13 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Sessions.InMemory +namespace CodeBeam.UltimateAuth.Sessions.InMemory; + +public static class ServiceCollectionExtensions { - public static class ServiceCollectionExtensions + public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) { - public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) - { - services.AddSingleton(); - // TODO: Discuss it to be singleton or scoped - services.AddScoped(); - return services; - } + services.AddSingleton(); + return services; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs index 05dc0c1a..f4f95708 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore { private readonly UltimateAuthTokenDbContext _db; @@ -12,14 +14,17 @@ public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, IUserIdConverterRe _db = db; } - public async Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default) + public async Task StoreAsync(TenantKey tenantId, StoredRefreshToken token, CancellationToken ct = default) { - if (token.TenantId != tenantId) + if (token.Tenant != tenantId) throw new InvalidOperationException("TenantId mismatch between context and token."); + if (token.ChainId is null) + throw new InvalidOperationException("Refresh token must have a ChainId before being stored."); + _db.RefreshTokens.Add(new RefreshTokenProjection { - TenantId = tenantId, + Tenant = tenantId, TokenHash = token.TokenHash, UserKey = token.UserKey, SessionId = token.SessionId, @@ -31,13 +36,13 @@ public async Task StoreAsync(string? tenantId, StoredRefreshToken token, Cancell await _db.SaveChangesAsync(ct); } - public async Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default) + public async Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default) { var e = await _db.RefreshTokens .AsNoTracking() .SingleOrDefaultAsync( x => x.TokenHash == tokenHash && - x.TenantId == tenantId, + x.Tenant == tenant, ct); if (e is null) @@ -45,7 +50,7 @@ public async Task StoreAsync(string? tenantId, StoredRefreshToken token, Cancell return new StoredRefreshToken { - TenantId = e.TenantId, + Tenant = e.Tenant, TokenHash = e.TokenHash, UserKey = e.UserKey, SessionId = e.SessionId, @@ -56,12 +61,12 @@ public async Task StoreAsync(string? tenantId, StoredRefreshToken token, Cancell }; } - public Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + public Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { var query = _db.RefreshTokens .Where(x => x.TokenHash == tokenHash && - x.TenantId == tenantId && + x.Tenant == tenant && x.RevokedAt == null); if (replacedByTokenHash == null) @@ -76,28 +81,28 @@ public Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revok ct); } - public Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) => _db.RefreshTokens .Where(x => - x.TenantId == tenantId && + x.Tenant == tenant && x.SessionId == sessionId.Value && x.RevokedAt == null) .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - public Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) => _db.RefreshTokens .Where(x => - x.TenantId == tenantId && + x.Tenant == tenant && x.ChainId == chainId && x.RevokedAt == null) .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - public Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { return _db.RefreshTokens .Where(x => - x.TenantId == tenantId && + 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/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index 14a759a2..138f800d 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; @@ -6,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; internal sealed class RefreshTokenProjection { public long Id { get; set; } // Surrogate PK - public string? TenantId { get; set; } + public TenantKey Tenant { get; set; } public string TokenHash { get; set; } = default!; public UserKey UserKey { get; set; } = default!; diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs index 5499bace..0dc5a1e5 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs @@ -1,9 +1,11 @@ -namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; internal sealed class RevokedTokenIdProjection { public long Id { get; set; } - public string? TenantId { get; set; } + public TenantKey Tenant { get; set; } public string Jti { get; set; } = default!; diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs index 7b958e2d..be84d6e1 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -24,14 +24,14 @@ protected override void OnModelCreating(ModelBuilder b) e.Property(x => x.TokenHash) .IsRequired(); - e.HasIndex(x => new { x.TenantId, x.TokenHash }) + e.HasIndex(x => new { x.Tenant, x.TokenHash }) .IsUnique(); - e.HasIndex(x => new { x.TenantId, x.UserKey }); - e.HasIndex(x => new { x.TenantId, x.SessionId }); - e.HasIndex(x => new { x.TenantId, x.ChainId }); - e.HasIndex(x => new { x.TenantId, x.ExpiresAt }); - e.HasIndex(x => new { x.TenantId, x.ReplacedByTokenHash }); + 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.ReplacedByTokenHash }); e.Property(x => x.ExpiresAt).IsRequired(); }); @@ -49,7 +49,7 @@ protected override void OnModelCreating(ModelBuilder b) e.HasIndex(x => x.Jti) .IsUnique(); - e.HasIndex(x => new { x.TenantId, x.Jti }); + e.HasIndex(x => new { x.Tenant, x.Jti }); e.Property(x => x.ExpiresAt) .IsRequired(); diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index 3d4b09b5..7046a019 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -1,34 +1,35 @@ using System.Collections.Concurrent; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tokens.InMemory; public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore { - private static string NormalizeTenant(string? tenantId) => tenantId ?? "__default__"; + private static string NormalizeTenant(string? tenantId) => tenantId ?? "__single__"; private readonly ConcurrentDictionary _tokens = new(); - public Task StoreAsync(string? tenantId, StoredRefreshToken token, CancellationToken ct = default) + public Task StoreAsync(TenantKey tenant, StoredRefreshToken token, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenantId), token.TokenHash); + var key = new TokenKey(NormalizeTenant(tenant), token.TokenHash); _tokens[key] = token; return Task.CompletedTask; } - public Task FindByHashAsync(string? tenantId, string tokenHash, CancellationToken ct = default) + public Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenantId), tokenHash); + var key = new TokenKey(NormalizeTenant(tenant), tokenHash); _tokens.TryGetValue(key, out var token); return Task.FromResult(token); } - public Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + public Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenantId), tokenHash); + var key = new TokenKey(NormalizeTenant(tenant), tokenHash); if (_tokens.TryGetValue(key, out var token) && !token.IsRevoked) { @@ -42,10 +43,8 @@ public Task RevokeAsync(string? tenantId, string tokenHash, DateTimeOffset revok return Task.CompletedTask; } - public Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) { - var tenant = NormalizeTenant(tenantId); - foreach (var (key, token) in _tokens) { if (key.TenantId == tenant && @@ -59,10 +58,8 @@ public Task RevokeBySessionAsync(string? tenantId, AuthSessionId sessionId, Date return Task.CompletedTask; } - public Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) { - var tenant = NormalizeTenant(tenantId); - foreach (var (key, token) in _tokens) { if (key.TenantId == tenant && @@ -76,10 +73,8 @@ public Task RevokeByChainAsync(string? tenantId, SessionChainId chainId, DateTim return Task.CompletedTask; } - public Task RevokeAllForUserAsync(string? tenantId, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { - var tenant = NormalizeTenant(tenantId); - foreach (var (key, token) in _tokens) { if (key.TenantId == tenant && diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs index 3a5b936b..f207ae2c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum MfaMethod { - public enum MfaMethod - { - Totp = 10, - Sms = 20, - Email = 30, - Passkey = 40 - } + Totp = 10, + Sms = 20, + Email = 30, + Passkey = 40 } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs deleted file mode 100644 index 04926b51..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record UserAccessDecision( - bool IsAllowed, - bool RequiresReauthentication, - string? DenyReason = null); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs index 0a0bc2de..caf74f5f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs @@ -1,12 +1,11 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserIdentifierDto { - public sealed record UserIdentifierDto - { - public required UserIdentifierType Type { get; init; } - public required string Value { get; init; } - public bool IsPrimary { get; init; } - public bool IsVerified { get; init; } - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? VerifiedAt { get; init; } - } + public required UserIdentifierType Type { get; init; } + public required string Value { get; init; } + public bool IsPrimary { get; init; } + public bool IsVerified { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs index 57ef44fd..4694a8c6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum UserIdentifierType { - public enum UserIdentifierType - { - Username, - Email, - Phone - } + Username, + Email, + Phone } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs index bf652782..736ab39b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs @@ -1,15 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +namespace CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Contracts +public sealed record UserMfaStatusDto { - public sealed record UserMfaStatusDto - { - public bool IsEnabled { get; init; } - public IReadOnlyCollection EnabledMethods { get; init; } = Array.Empty(); - public MfaMethod? DefaultMethod { get; init; } - } + 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/UserProfileInput.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs deleted file mode 100644 index 2a9f029d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record UserProfileInput -{ - public string? FirstName { get; init; } - public string? LastName { get; init; } - public string? DisplayName { get; init; } - public string? Email { get; init; } - public string? Phone { get; init; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs index f02ebfcb..9aff5b20 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs @@ -1,21 +1,20 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum UserStatus { - public enum UserStatus - { - Active = 0, + Active = 0, - SelfSuspended = 10, + SelfSuspended = 10, - Disabled = 20, - Suspended = 30, + Disabled = 20, + Suspended = 30, - Locked = 40, - RiskHold = 50, + Locked = 40, + RiskHold = 50, - PendingActivation = 60, - PendingVerification = 70, + PendingActivation = 60, + PendingVerification = 70, - Deactivated = 80, - } + Deactivated = 80, } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs index 5ebd9a46..ce89dea8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs @@ -1,26 +1,25 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserViewDto { - public sealed record UserViewDto - { - public string UserKey { get; init; } = default!; + public string UserKey { get; init; } = default!; - public string? UserName { get; init; } - public string? PrimaryEmail { get; init; } - public string? PrimaryPhone { 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? 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 bool EmailVerified { get; init; } - public bool PhoneVerified { get; init; } + public bool EmailVerified { get; init; } + public bool PhoneVerified { get; init; } - public DateTimeOffset? CreatedAt { get; init; } - //public DateTimeOffset? LastLoginAt { get; init; } + public DateTimeOffset? CreatedAt { get; init; } + //public DateTimeOffset? LastLoginAt { get; init; } - public IReadOnlyDictionary? Metadata { get; init; } - } + public IReadOnlyDictionary? Metadata { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs index 39de26bd..0ae55149 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record AddUserIdentifierRequest { - public sealed record AddUserIdentifierRequest - { - public UserIdentifierType Type { get; init; } - public string Value { get; init; } = default!; - public bool IsPrimary { get; init; } - } + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; + public bool IsPrimary { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs index 7c5c9e28..c14eef0d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record BeginMfaSetupRequest { - public sealed record BeginMfaSetupRequest - { - public MfaMethod Method { get; init; } - } + public MfaMethod Method { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs index cf6a530f..d417d6a8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record ChangeUserIdentifierRequest { - public sealed record ChangeUserIdentifierRequest - { - public required UserIdentifierType Type { get; init; } - public required string NewValue { get; init; } - public string? Reason { get; init; } - } + 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 index d88b519d..5b7561e3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -1,10 +1,9 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class ChangeUserStatusAdminRequest { - public sealed class ChangeUserStatusAdminRequest - { - public required UserKey UserKey { get; init; } - public required UserStatus NewStatus { get; init; } - } + public required UserKey UserKey { get; init; } + public required UserStatus 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 index aaa7ad0c..dba43740 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public class ChangeUserStatusSelfRequest { - public class ChangeUserStatusSelfRequest - { - public required UserStatus NewStatus { get; init; } - } + public required UserStatus NewStatus { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs index ab1ba705..ad398643 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record CompleteMfaSetupRequest { - public sealed record CompleteMfaSetupRequest - { - public MfaMethod Method { get; init; } - public string VerificationCode { get; init; } = default!; - } + public MfaMethod Method { get; init; } + public string VerificationCode { get; init; } = default!; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs index 33b95744..73509817 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs @@ -1,11 +1,10 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record DeleteUserIdentifierRequest { - public sealed record DeleteUserIdentifierRequest - { - public required UserIdentifierType Type { get; init; } - public required string Value { get; init; } - public DeleteMode Mode { get; init; } = DeleteMode.Soft; - } + public required UserIdentifierType Type { get; init; } + public required string Value { get; init; } + public DeleteMode Mode { get; init; } = DeleteMode.Soft; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs index f24058c1..a6721e42 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs @@ -1,10 +1,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class DeleteUserRequest { - public sealed class DeleteUserRequest - { - public DeleteMode Mode { get; init; } = DeleteMode.Soft; - } + public DeleteMode Mode { get; init; } = DeleteMode.Soft; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs index 63da5424..755a8de7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record DisableMfaRequest { - public sealed record DisableMfaRequest - { - public MfaMethod? Method { get; init; } // null = all - } + public MfaMethod? Method { get; init; } // null = all } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs index e8482441..985e12e5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs @@ -1,30 +1,31 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +/// +/// Request to register a new user with credentials. +/// +public sealed class RegisterUserRequest { /// - /// Request to register a new user with credentials. + /// Unique user identifier (username, email, or external id). + /// Interpretation is application-specific. /// - public sealed class RegisterUserRequest - { - /// - /// Unique user identifier (username, email, or external id). - /// Interpretation is application-specific. - /// - public required string Identifier { get; init; } + public required string Identifier { get; init; } - /// - /// Plain-text password. - /// Will be hashed by the configured password hasher. - /// - public required string Password { get; init; } + /// + /// Plain-text password. + /// Will be hashed by the configured password hasher. + /// + public required string Password { get; init; } - /// - /// Optional tenant identifier. - /// - public string? TenantId { get; init; } + /// + /// Optional tenant identifier. + /// + public TenantKey Tenant { get; init; } - /// - /// Optional initial claims or metadata. - /// - public IReadOnlyDictionary? Metadata { get; init; } - } + /// + /// Optional initial claims or metadata. + /// + public IReadOnlyDictionary? Metadata { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs deleted file mode 100644 index 66802a50..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts -{ - public sealed record SetPrimaryUserIdentifierRequest - { - public UserIdentifierType Type { get; init; } - public string Value { get; init; } = default!; - } -} 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..b435933a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record SetPrimaryUserIdentifierRequest +{ + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs index f17e69b8..2dc8ef76 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UnsetPrimaryUserIdentifierRequest { - public sealed record UnsetPrimaryUserIdentifierRequest - { - public UserIdentifierType Type { get; init; } - public string Value { get; init; } = default!; - } + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs index 880d5916..50a9976d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UpdateUserIdentifierRequest { - public sealed record UpdateUserIdentifierRequest - { - public UserIdentifierType Type { get; init; } - public string OldValue { get; init; } = default!; - public string NewValue { get; init; } = default!; - } + public UserIdentifierType Type { get; init; } + public string OldValue { get; init; } = default!; + public string NewValue { get; init; } = default!; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs index 30049f2f..765fecdb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record VerifyUserIdentifierRequest { - public sealed record VerifyUserIdentifierRequest - { - public required UserIdentifierType Type { get; init; } - public required string Value { get; init; } - } + public required UserIdentifierType Type { get; init; } + public required string Value { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs index 0285c0a3..7fb41c67 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record BeginMfaSetupResult { - public sealed record BeginMfaSetupResult - { - public MfaMethod Method { get; init; } - public string? SharedSecret { get; init; } // TOTP - public string? QrCodeUri { get; init; } // TOTP - } + 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 index a1e4b8e7..a0def373 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs @@ -1,7 +1,6 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record GetUserIdentifiersResult { - public sealed record GetUserIdentifiersResult - { - public required IReadOnlyCollection Identifiers { get; init; } - } + 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 index db7376d8..da9f1da4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs @@ -1,12 +1,11 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierChangeResult { - public sealed record IdentifierChangeResult - { - public bool Succeeded { get; init; } - public string? FailureReason { get; init; } + public bool Succeeded { get; init; } + public string? FailureReason { get; init; } - public static IdentifierChangeResult Success() => new() { Succeeded = true }; + public static IdentifierChangeResult Success() => new() { Succeeded = true }; - public static IdentifierChangeResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; - } + 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 index e62ec71e..00145603 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs @@ -1,11 +1,10 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierDeleteResult { - public sealed record IdentifierDeleteResult - { - public bool Succeeded { get; init; } - public string? FailureReason { get; init; } + 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 }; - } + 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/IdentifierVerificationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs index 6c4b4464..b642949d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs @@ -1,12 +1,11 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierVerificationResult { - public sealed record IdentifierVerificationResult - { - public bool Succeeded { get; init; } - public string? FailureReason { get; init; } + public bool Succeeded { get; init; } + public string? FailureReason { get; init; } - public static IdentifierVerificationResult Success() => new() { Succeeded = true }; + public static IdentifierVerificationResult Success() => new() { Succeeded = true }; - public static IdentifierVerificationResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; - } + public static IdentifierVerificationResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; } 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..e6209864 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +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.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton, InMemoryUserIdProvider>(); + + // Seed never try add + services.AddSingleton(); + + return services; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs deleted file mode 100644 index 641d5c47..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Users.Reference; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace CodeBeam.UltimateAuth.Users.InMemory.Extensions -{ - public static class UltimateAuthUsersInMemoryExtensions - { - public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) - { - services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton, InMemoryUserIdProvider>(); - - // Seed never try add - services.AddSingleton(); - - return services; - } - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs index 8ae70e3c..7f5c452f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs @@ -1,15 +1,13 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.InMemory -{ - public sealed class InMemoryUserIdProvider : IInMemoryUserIdProvider - { - private static readonly UserKey Admin = UserKey.FromString("admin"); - private static readonly UserKey User = UserKey.FromString("user"); +namespace CodeBeam.UltimateAuth.Users.InMemory; - public UserKey GetAdminUserId() => Admin; - public UserKey GetUserUserId() => User; - } +public sealed class InMemoryUserIdProvider : IInMemoryUserIdProvider +{ + private static readonly UserKey Admin = UserKey.FromString("admin"); + private static readonly UserKey User = UserKey.FromString("user"); + public UserKey GetAdminUserId() => Admin; + public UserKey GetUserUserId() => User; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs index bd7b9ddb..55077d7b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs @@ -1,13 +1,12 @@ -using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Users.InMemory +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider { - internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider + public Task GetAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) { - public Task GetAsync(string? tenantId, TUserId userId, CancellationToken ct = default) - { - // InMemory default: no MFA, no lockout, no risk signals - return Task.FromResult(null); - } + // InMemory default: no MFA, no lockout, no risk signals + return Task.FromResult(null); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index 4a04f938..61e694c0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -1,73 +1,72 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -namespace CodeBeam.UltimateAuth.Users.InMemory +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSeedContributor : ISeedContributor { - internal sealed class InMemoryUserSeedContributor : ISeedContributor - { - public int Order => 0; + public int Order => 0; - private readonly IUserLifecycleStore _lifecycle; - private readonly IUserProfileStore _profiles; - private readonly IUserIdentifierStore _identifiers; - private readonly IInMemoryUserIdProvider _ids; - private readonly IClock _clock; + private readonly IUserLifecycleStore _lifecycle; + private readonly IUserProfileStore _profiles; + private readonly IUserIdentifierStore _identifiers; + private readonly IInMemoryUserIdProvider _ids; + private readonly IClock _clock; - public InMemoryUserSeedContributor( - IUserLifecycleStore lifecycle, - IUserProfileStore profiles, - IUserIdentifierStore identifiers, - IInMemoryUserIdProvider ids, - IClock clock) - { - _lifecycle = lifecycle; - _profiles = profiles; - _ids = ids; - _identifiers = identifiers; - _clock = clock; - } + public InMemoryUserSeedContributor( + IUserLifecycleStore lifecycle, + IUserProfileStore profiles, + IUserIdentifierStore identifiers, + IInMemoryUserIdProvider ids, + IClock clock) + { + _lifecycle = lifecycle; + _profiles = profiles; + _ids = ids; + _identifiers = identifiers; + _clock = clock; + } - public async Task SeedAsync(string? tenantId, CancellationToken ct = default) - { - await SeedUserAsync(tenantId, _ids.GetAdminUserId(), "Administrator", "admin", ct); - await SeedUserAsync(tenantId, _ids.GetUserUserId(), "User", "user", ct); - } + public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) + { + await SeedUserAsync(tenant, _ids.GetAdminUserId(), "Administrator", "admin", ct); + await SeedUserAsync(tenant, _ids.GetUserUserId(), "User", "user", ct); + } - private async Task SeedUserAsync(string? tenantId, UserKey userKey, string displayName, string username, CancellationToken ct) - { - if (await _lifecycle.ExistsAsync(tenantId, userKey, ct)) - return; + private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string username, CancellationToken ct) + { + if (await _lifecycle.ExistsAsync(tenant, userKey, ct)) + return; - await _lifecycle.CreateAsync(tenantId, - new UserLifecycle - { - UserKey = userKey, - Status = UserStatus.Active, - CreatedAt = _clock.UtcNow - }, ct); + await _lifecycle.CreateAsync(tenant, + new UserLifecycle + { + UserKey = userKey, + Status = UserStatus.Active, + CreatedAt = _clock.UtcNow + }, ct); - await _profiles.CreateAsync(tenantId, - new UserProfile - { - UserKey = userKey, - DisplayName = displayName, - CreatedAt = _clock.UtcNow - }, ct); + await _profiles.CreateAsync(tenant, + new UserProfile + { + UserKey = userKey, + DisplayName = displayName, + CreatedAt = _clock.UtcNow + }, ct); - await _identifiers.CreateAsync(tenantId, - new UserIdentifier - { - UserKey = userKey, - Type = UserIdentifierType.Username, - Value = username, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); - } + await _identifiers.CreateAsync(tenant, + new UserIdentifier + { + UserKey = userKey, + Type = UserIdentifierType.Username, + Value = username, + IsPrimary = true, + IsVerified = true, + CreatedAt = _clock.UtcNow + }, ct); } - } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index ec9f36bf..36733c96 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -1,181 +1,181 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -namespace CodeBeam.UltimateAuth.Users.InMemory +namespace CodeBeam.UltimateAuth.Users.InMemory; + +public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore { - public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore + private readonly Dictionary<(TenantKey Tenant, UserIdentifierType Type, string Value), UserIdentifier> _store = new(); + + public Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) { - private readonly Dictionary<(string? TenantId, UserIdentifierType Type, string Value), UserIdentifier> _store = new(); + return Task.FromResult(_store.TryGetValue((tenant, type, value), out var id) && !id.IsDeleted); + } - public Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) - { - return Task.FromResult(_store.TryGetValue((tenantId, type, value), out var id) && !id.IsDeleted); - } + public Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) + { + if (!_store.TryGetValue((tenant, type, value), out var id)) + return Task.FromResult(null); - public Task GetAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenantId, type, value), out var id)) - return Task.FromResult(null); + if (id.IsDeleted) + return Task.FromResult(null); - if (id.IsDeleted) - return Task.FromResult(null); + return Task.FromResult(id); + } - return Task.FromResult(id); - } + public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var result = _store.Values + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == userKey) + .Where(x => !x.IsDeleted) + .OrderByDescending(x => x.IsPrimary) + .ThenBy(x => x.CreatedAt) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } - public Task> GetByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - var result = _store.Values - .Where(x => x.TenantId == tenantId) - .Where(x => x.UserKey == userKey) - .Where(x => !x.IsDeleted) - .OrderByDescending(x => x.IsPrimary) - .ThenBy(x => x.CreatedAt) - .ToList() - .AsReadOnly(); - - return Task.FromResult>(result); - } + public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default) + { + var key = (tenant, identifier.Type, identifier.Value); - public Task CreateAsync(string? tenantId, UserIdentifier identifier, CancellationToken ct = default) - { - var key = (tenantId, identifier.Type, identifier.Value); + if (_store.TryGetValue(key, out var existing) && !existing.IsDeleted) + throw new InvalidOperationException("Identifier already exists."); - if (_store.TryGetValue(key, out var existing) && !existing.IsDeleted) - throw new InvalidOperationException("Identifier already exists."); + _store[key] = identifier; + return Task.CompletedTask; + } - _store[key] = identifier; - return Task.CompletedTask; - } + public Task UpdateValueAsync(TenantKey tenant, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - public Task UpdateValueAsync(string? tenantId, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + if (string.Equals(oldValue, newValue, StringComparison.Ordinal)) + throw new InvalidOperationException("identifier_value_unchanged"); - if (string.Equals(oldValue, newValue, StringComparison.Ordinal)) - throw new InvalidOperationException("identifier_value_unchanged"); + var oldKey = (tenant, type, oldValue); - var oldKey = (tenantId, type, oldValue); + if (!_store.TryGetValue(oldKey, out var identifier) || identifier.IsDeleted) + throw new InvalidOperationException("identifier_not_found"); - if (!_store.TryGetValue(oldKey, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + var newKey = (tenant, type, newValue); - var newKey = (tenantId, type, newValue); + if (_store.ContainsKey(newKey)) + throw new InvalidOperationException("identifier_value_already_exists"); - if (_store.ContainsKey(newKey)) - throw new InvalidOperationException("identifier_value_already_exists"); + _store.Remove(oldKey); - _store.Remove(oldKey); + identifier.Value = newValue; + identifier.IsVerified = false; + identifier.VerifiedAt = null; + identifier.UpdatedAt = updatedAt; - identifier.Value = newValue; - identifier.IsVerified = false; - identifier.VerifiedAt = null; - identifier.UpdatedAt = updatedAt; + _store[newKey] = identifier; - _store[newKey] = identifier; + return Task.CompletedTask; + } - return Task.CompletedTask; - } + public Task MarkVerifiedAsync(TenantKey tenant, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default) + { + var key = (tenant, type, value); - public Task MarkVerifiedAsync(string? tenantId, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default) - { - var key = (tenantId, type, value); + if (!_store.TryGetValue(key, out var id) || id.IsDeleted) + throw new InvalidOperationException("Identifier not found."); - if (!_store.TryGetValue(key, out var id) || id.IsDeleted) - throw new InvalidOperationException("Identifier not found."); + if (id.IsVerified) + return Task.CompletedTask; - if (id.IsVerified) - return Task.CompletedTask; + id.IsVerified = true; + id.VerifiedAt = verifiedAt; - id.IsVerified = true; - id.VerifiedAt = verifiedAt; + return Task.CompletedTask; + } - return Task.CompletedTask; + public Task SetPrimaryAsync(TenantKey tenant, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) + { + foreach (var id in _store.Values.Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.Type == type && + !x.IsDeleted && + x.IsPrimary)) + { + id.IsPrimary = false; } - public Task SetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) - { - foreach (var id in _store.Values.Where(x => - x.TenantId == tenantId && - x.UserKey == userKey && - x.Type == type && - !x.IsDeleted && - x.IsPrimary)) - { - id.IsPrimary = false; - } + var key = (tenant, type, value); - var key = (tenantId, type, value); + if (!_store.TryGetValue(key, out var target) || target.IsDeleted) + throw new InvalidOperationException("Identifier not found."); - if (!_store.TryGetValue(key, out var target) || target.IsDeleted) - throw new InvalidOperationException("Identifier not found."); + target.IsPrimary = true; + return Task.CompletedTask; + } - target.IsPrimary = true; - return Task.CompletedTask; - } + public Task UnsetPrimaryAsync(TenantKey tenant, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) + { + var key = (tenant, type, value); - public Task UnsetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) - { - var key = (tenantId, type, value); + if (!_store.TryGetValue(key, out var target) || target.IsDeleted) + throw new InvalidOperationException("Identifier not found."); - if (!_store.TryGetValue(key, out var target) || target.IsDeleted) - throw new InvalidOperationException("Identifier not found."); + target.IsPrimary = false; + return Task.CompletedTask; + } + + public Task DeleteAsync(TenantKey tenant, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + var key = (tenant, type, value); - target.IsPrimary = false; + if (!_store.TryGetValue(key, out var id)) return Task.CompletedTask; - } - public Task DeleteAsync(string? tenantId, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + if (mode == DeleteMode.Hard) { - var key = (tenantId, type, value); - - if (!_store.TryGetValue(key, out var id)) - return Task.CompletedTask; + _store.Remove(key); + return Task.CompletedTask; + } - if (mode == DeleteMode.Hard) - { - _store.Remove(key); - return Task.CompletedTask; - } + if (id.IsDeleted) + return Task.CompletedTask; - if (id.IsDeleted) - return Task.CompletedTask; + id.IsDeleted = true; + id.DeletedAt = deletedAt; + id.IsPrimary = false; - id.IsDeleted = true; - id.DeletedAt = deletedAt; - id.IsPrimary = false; + return Task.CompletedTask; + } - return Task.CompletedTask; - } + public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + var identifiers = _store.Values + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == userKey) + .ToList(); - public Task DeleteByUserAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + foreach (var id in identifiers) { - var identifiers = _store.Values - .Where(x => x.TenantId == tenantId) - .Where(x => x.UserKey == userKey) - .ToList(); - - foreach (var id in identifiers) + if (mode == DeleteMode.Hard) { - if (mode == DeleteMode.Hard) - { - _store.Remove((tenantId, id.Type, id.Value)); - } - else - { - if (id.IsDeleted) - continue; - - id.IsDeleted = true; - id.DeletedAt = deletedAt; - id.IsPrimary = false; - } + _store.Remove((tenant, id.Type, id.Value)); } + else + { + if (id.IsDeleted) + continue; - return Task.CompletedTask; + id.IsDeleted = true; + id.DeletedAt = deletedAt; + id.IsPrimary = false; + } } + return Task.CompletedTask; } + } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index 0302cc22..aea09138 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; @@ -7,16 +8,16 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserLifecycleStore : IUserLifecycleStore { - private readonly Dictionary<(string?, UserKey), UserLifecycle> _store = new(); + private readonly Dictionary<(TenantKey, UserKey), UserLifecycle> _store = new(); - public Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - return Task.FromResult(_store.TryGetValue((tenantId, userKey), out var entity) && !entity.IsDeleted); + return Task.FromResult(_store.TryGetValue((tenant, userKey), out var entity) && !entity.IsDeleted); } - public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - if (!_store.TryGetValue((tenantId, userKey), out var entity)) + if (!_store.TryGetValue((tenant, userKey), out var entity)) return Task.FromResult(null); if (entity.IsDeleted) @@ -25,11 +26,11 @@ public Task ExistsAsync(string? tenantId, UserKey userKey, CancellationTok return Task.FromResult(entity); } - public Task> QueryAsync(string? tenantId, UserLifecycleQuery query, CancellationToken ct = default) + public Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) { var baseQuery = _store.Values .Where(x => x?.UserKey != null) - .Where(x => x.TenantId == tenantId); + .Where(x => x.Tenant == tenant); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => !x.IsDeleted); @@ -49,9 +50,9 @@ public Task> QueryAsync(string? tenantId, UserLifecyc return Task.FromResult(new PagedResult(items, totalCount)); } - public Task CreateAsync(string? tenantId, UserLifecycle lifecycle, CancellationToken ct = default) + public Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default) { - var key = (tenantId, lifecycle.UserKey); + var key = (tenant, lifecycle.UserKey); if (_store.ContainsKey(key)) throw new InvalidOperationException("UserLifecycle already exists."); @@ -60,9 +61,9 @@ public Task CreateAsync(string? tenantId, UserLifecycle lifecycle, CancellationT return Task.CompletedTask; } - public Task ChangeStatusAsync(string? tenantId, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default) + public Task ChangeStatusAsync(TenantKey tenant, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default) { - if (!_store.TryGetValue((tenantId, userKey), out var entity) || entity.IsDeleted) + if (!_store.TryGetValue((tenant, userKey), out var entity) || entity.IsDeleted) throw new InvalidOperationException("UserLifecycle not found."); entity.Status = newStatus; @@ -71,9 +72,9 @@ public Task ChangeStatusAsync(string? tenantId, UserKey userKey, UserStatus newS return Task.CompletedTask; } - public Task ChangeSecurityStampAsync(string? tenantId, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default) + public Task ChangeSecurityStampAsync(TenantKey tenant, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default) { - if (!_store.TryGetValue((tenantId, userKey), out var entity) || entity.IsDeleted) + if (!_store.TryGetValue((tenant, userKey), out var entity) || entity.IsDeleted) throw new InvalidOperationException("UserLifecycle not found."); if (entity.SecurityStamp == newSecurityStamp) @@ -85,9 +86,9 @@ public Task ChangeSecurityStampAsync(string? tenantId, UserKey userKey, Guid new return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { - var key = (tenantId, userKey); + var key = (tenant, userKey); if (!_store.TryGetValue(key, out var entity)) return Task.CompletedTask; diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 162f1100..b0ee2678 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,21 +1,22 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserProfileStore : IUserProfileStore { - private readonly Dictionary<(string? TenantId, UserKey UserKey), UserProfile> _store = new(); + private readonly Dictionary<(TenantKey Tenant, UserKey UserKey), UserProfile> _store = new(); - public Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - return Task.FromResult(_store.TryGetValue((tenantId, userKey), out var profile) && profile.DeletedAt == null); + return Task.FromResult(_store.TryGetValue((tenant, userKey), out var profile) && profile.DeletedAt == null); } - public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - if (!_store.TryGetValue((tenantId, userKey), out var profile)) + if (!_store.TryGetValue((tenant, userKey), out var profile)) return Task.FromResult(null); if (profile.DeletedAt != null) @@ -24,10 +25,10 @@ public Task ExistsAsync(string? tenantId, UserKey userKey, CancellationTok return Task.FromResult(profile); } - public Task> QueryAsync(string? tenantId, UserProfileQuery query, CancellationToken ct = default) + public Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) { var baseQuery = _store.Values - .Where(x => x.TenantId == tenantId); + .Where(x => x.Tenant == tenant); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => x.DeletedAt == null); @@ -44,9 +45,9 @@ public Task> QueryAsync(string? tenantId, UserProfileQu return Task.FromResult(new PagedResult(items, totalCount)); } - public Task CreateAsync(string? tenantId, UserProfile profile, CancellationToken ct = default) + public Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default) { - var key = (tenantId, profile.UserKey); + var key = (tenant, profile.UserKey); if (_store.ContainsKey(key)) throw new InvalidOperationException("UserProfile already exists."); @@ -55,9 +56,9 @@ public Task CreateAsync(string? tenantId, UserProfile profile, CancellationToken return Task.CompletedTask; } - public Task UpdateAsync(string? tenantId, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default) + public Task UpdateAsync(TenantKey tenant, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default) { - var key = (tenantId, userKey); + var key = (tenant, userKey); if (!_store.TryGetValue(key, out var existing) || existing.DeletedAt != null) throw new InvalidOperationException("UserProfile not found."); @@ -78,9 +79,9 @@ public Task UpdateAsync(string? tenantId, UserKey userKey, UserProfileUpdate upd return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { - var key = (tenantId, userKey); + var key = (tenant, userKey); if (!_store.TryGetValue(key, out var profile)) return Task.CompletedTask; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs index c35ef39c..1b7b8e20 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class AddUserIdentifierCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public AddUserIdentifierCommand(Func execute) - { - _execute = execute; - } +internal sealed class AddUserIdentifierCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public AddUserIdentifierCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs index d6299789..954c6fa6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs @@ -1,18 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class ChangeUserStatusCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public ChangeUserStatusCommand(Func execute) - { - _execute = execute; - } +internal sealed class ChangeUserStatusCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public ChangeUserStatusCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs index b29f57d5..675ebde9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs @@ -1,19 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class CreateUserCommand : IAccessCommand - { - private readonly Func> _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public CreateUserCommand(Func> execute) - { - _execute = execute; - } +internal sealed class CreateUserCommand : IAccessCommand +{ + private readonly Func> _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public CreateUserCommand(Func> execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs index 96039d38..da4acc96 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs @@ -1,18 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class DeleteUserCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public DeleteUserCommand(Func execute) - { - _execute = execute; - } +internal sealed class DeleteUserCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public DeleteUserCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs index 12f6b722..59b9d079 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs @@ -1,18 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class DeleteUserIdentifierCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public DeleteUserIdentifierCommand(Func execute) - { - _execute = execute; - } +internal sealed class DeleteUserIdentifierCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public DeleteUserIdentifierCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs index 813db3dd..90302ae1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs index 8da5b649..d5eb10f2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs @@ -1,22 +1,16 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class GetUserIdentifierCommand : IAccessCommand - { - private readonly Func> _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public GetUserIdentifierCommand(Func> execute) - { - _execute = execute; - } +internal sealed class GetUserIdentifierCommand : IAccessCommand +{ + private readonly Func> _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public GetUserIdentifierCommand(Func> execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs index b931025e..a8219862 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs @@ -1,19 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class GetUserIdentifiersCommand : IAccessCommand> - { - private readonly Func>> _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public GetUserIdentifiersCommand(Func>> execute) - { - _execute = execute; - } +internal sealed class GetUserIdentifiersCommand : IAccessCommand> +{ + private readonly Func>> _execute; - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public GetUserIdentifiersCommand(Func>> execute) + { + _execute = execute; } + + public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs index 4912a6b3..82e7fe12 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs index 800d6c0e..8a56df8c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class SetPrimaryUserIdentifierCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public SetPrimaryUserIdentifierCommand(Func execute) - { - _execute = execute; - } +internal sealed class SetPrimaryUserIdentifierCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public SetPrimaryUserIdentifierCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs index f7f21b72..48a7ad89 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class UnsetPrimaryUserIdentifierCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public UnsetPrimaryUserIdentifierCommand(Func execute) - { - _execute = execute; - } +internal sealed class UnsetPrimaryUserIdentifierCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public UnsetPrimaryUserIdentifierCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs index d2521fad..1005453d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class UpdateUserIdentifierCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public UpdateUserIdentifierCommand(Func execute) - { - _execute = execute; - } +internal sealed class UpdateUserIdentifierCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public UpdateUserIdentifierCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs index b9d550c3..aa38706a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Users.Reference; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs index 7cd93350..28602a36 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs @@ -1,21 +1,15 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace CodeBeam.UltimateAuth.Users.Reference.Commands -{ - internal sealed class UserIdentifierExistsCommand : IAccessCommand - { - private readonly Func> _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public UserIdentifierExistsCommand(Func> execute) - { - _execute = execute; - } +internal sealed class UserIdentifierExistsCommand : IAccessCommand +{ + private readonly Func> _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public UserIdentifierExistsCommand(Func> execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs index 8e4f8985..186433d6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs @@ -1,18 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class VerifyUserIdentifierCommand : IAccessCommand - { - private readonly Func _execute; +namespace CodeBeam.UltimateAuth.Users.Reference; - public VerifyUserIdentifierCommand(Func execute) - { - _execute = execute; - } +internal sealed class VerifyUserIdentifierCommand : IAccessCommand +{ + private readonly Func _execute; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public VerifyUserIdentifierCommand(Func execute) + { + _execute = execute; } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs index 980c39f9..a6c6a944 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -1,13 +1,12 @@ using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserLifecycleQuery { - public sealed class UserLifecycleQuery - { - public bool IncludeDeleted { get; init; } - public UserStatus? Status { get; init; } + public bool IncludeDeleted { get; init; } + public UserStatus? Status { get; init; } - public int Skip { get; init; } - public int Take { get; init; } = 50; - } + public int Skip { get; init; } + public int Take { get; init; } = 50; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs index 9bc8a3ae..d0cd9262 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -1,10 +1,9 @@ -namespace CodeBeam.UltimateAuth.Users.Reference +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserProfileQuery { - public sealed class UserProfileQuery - { - public bool IncludeDeleted { get; init; } + public bool IncludeDeleted { get; init; } - public int Skip { get; init; } - public int Take { get; init; } = 50; - } + public int Skip { get; init; } + public int Take { get; init; } = 50; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index 96c2f27f..e4ff872d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -1,11 +1,12 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public sealed record UserIdentifier { - public string? TenantId { get; set; } + public TenantKey Tenant { get; set; } public UserKey UserKey { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 017b25b5..085a2e71 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -1,11 +1,12 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public sealed record class UserLifecycle { - public string? TenantId { get; set; } + public TenantKey Tenant { get; set; } public UserKey UserKey { get; init; } = default!; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index b8cb4a70..15c9ee22 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -1,11 +1,12 @@ 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 record class UserProfile { - public string? TenantId { get; set; } + public TenantKey Tenant { get; set; } public UserKey UserKey { get; init; } = default!; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs similarity index 98% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs rename to src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 87b99025..3b67efe7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -8,13 +8,13 @@ namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class DefaultUserEndpointHandler : IUserEndpointHandler +public sealed class UserEndpointHandler : IUserEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAccessContextFactory _accessContextFactory; private readonly IUserApplicationService _users; - public DefaultUserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserApplicationService users) + public UserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserApplicationService users) { _authFlow = authFlow; _accessContextFactory = accessContextFactory; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 30df9de9..80d6bbc3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -16,7 +16,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs index ccfee913..1c3df9c4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -1,18 +1,17 @@ using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference +namespace CodeBeam.UltimateAuth.Users.Reference; + +public static class UserIdentifierMapper { - public static class UserIdentifierMapper - { - public static UserIdentifierDto ToDto(UserIdentifier record) - => new() - { - Type = record.Type, - Value = record.Value, - IsPrimary = record.IsPrimary, - IsVerified = record.IsVerified, - CreatedAt = record.CreatedAt, - VerifiedAt = record.VerifiedAt - }; - } + public static UserIdentifierDto ToDto(UserIdentifier record) + => new() + { + Type = record.Type, + Value = record.Value, + IsPrimary = record.IsPrimary, + IsVerified = record.IsVerified, + CreatedAt = record.CreatedAt, + VerifiedAt = record.VerifiedAt + }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index 116083b5..5d0a536c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -19,18 +19,17 @@ public static UserViewDto ToDto(UserProfile profile) }; public static UserProfileUpdate ToUpdate(UpdateProfileRequest request) - => new() - { - FirstName = request.FirstName, - LastName = request.LastName, - DisplayName = request.DisplayName, - BirthDate = request.BirthDate, - Gender = request.Gender, - Bio = request.Bio, - Language = request.Language, - TimeZone = request.TimeZone, - Culture = request.Culture, - Metadata = request.Metadata - }; - + => new() + { + FirstName = request.FirstName, + LastName = request.LastName, + DisplayName = request.DisplayName, + BirthDate = request.BirthDate, + Gender = request.Gender, + Bio = request.Bio, + Language = request.Language, + TimeZone = request.TimeZone, + Culture = request.Culture, + Metadata = request.Metadata + }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 494ad49c..b01bc97f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -1,37 +1,36 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserApplicationService { - public interface IUserApplicationService - { - Task GetMeAsync(AccessContext context, CancellationToken ct = default); - Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); + Task GetMeAsync(AccessContext context, CancellationToken ct = default); + Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); - Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); + Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); - Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); + Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); - Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); + Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); - Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default); + Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default); - Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); + Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); - Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); + Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); - Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default); + Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default); - Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default); + Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default); - Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default); + Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default); - Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default); + Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default); - Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default); + Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default); - Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default); + Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default); - Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, 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 index 8d396f83..179ed6f2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -1,10 +1,10 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Abstractions; using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Commands; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -40,7 +40,7 @@ public async Task GetMeAsync(AccessContext context, CancellationTok if (context.ActorUserKey is null) throw new UnauthorizedAccessException(); - return await BuildUserViewAsync(context.ResourceTenantId, context.ActorUserKey.Value, innerCt); + return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -53,7 +53,7 @@ public async Task GetUserProfileAsync(AccessContext context, Cancel // Target user MUST exist in context var targetUserKey = context.GetTargetUserKey(); - return await BuildUserViewAsync(context.ResourceTenantId, targetUserKey, innerCt); + return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); }); @@ -72,7 +72,7 @@ public async Task CreateUserAsync(AccessContext context, Creat return UserCreateResult.Failed("primary_identifier_type_required"); } - await _lifecycleStore.CreateAsync(context.ResourceTenantId, + await _lifecycleStore.CreateAsync(context.ResourceTenant, new UserLifecycle { UserKey = userKey, @@ -81,7 +81,7 @@ await _lifecycleStore.CreateAsync(context.ResourceTenantId, }, innerCt); - await _profileStore.CreateAsync(context.ResourceTenantId, + await _profileStore.CreateAsync(context.ResourceTenant, new UserProfile { UserKey = userKey, @@ -101,7 +101,7 @@ await _profileStore.CreateAsync(context.ResourceTenantId, if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is not null) { - await _identifierStore.CreateAsync(context.ResourceTenantId, + await _identifierStore.CreateAsync(context.ResourceTenant, new UserIdentifier { UserKey = userKey, @@ -117,7 +117,7 @@ await _identifierStore.CreateAsync(context.ResourceTenantId, foreach (var integration in _integrations) { - await integration.OnUserCreatedAsync(context.ResourceTenantId, userKey, request, innerCt); + await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, innerCt); } return UserCreateResult.Success(userKey); @@ -138,7 +138,7 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C }; var targetUserKey = context.GetTargetUserKey(); - var current = await _lifecycleStore.GetAsync(context.ResourceTenantId, targetUserKey, innerCt); + var current = await _lifecycleStore.GetAsync(context.ResourceTenant, targetUserKey, innerCt); if (current is null) throw new InvalidOperationException("user_not_found"); @@ -152,7 +152,7 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C throw new InvalidOperationException("admin_cannot_set_self_status"); } - await _lifecycleStore.ChangeStatusAsync(context.ResourceTenantId, targetUserKey, newStatus, _clock.UtcNow, innerCt); + await _lifecycleStore.ChangeStatusAsync(context.ResourceTenant, targetUserKey, newStatus, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -165,7 +165,7 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq var targetUserKey = context.GetTargetUserKey(); var update = UserProfileMapper.ToUpdate(request); - await _profileStore.UpdateAsync(context.ResourceTenantId, targetUserKey, update, _clock.UtcNow, innerCt); + await _profileStore.UpdateAsync(context.ResourceTenant, targetUserKey, update, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -176,7 +176,7 @@ public async Task> GetIdentifiersByUserAsync(Ac var command = new GetUserIdentifiersCommand(async innerCt => { var targetUserKey = context.GetTargetUserKey(); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, targetUserKey, innerCt); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, targetUserKey, innerCt); return identifiers.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); }); @@ -188,7 +188,7 @@ public async Task> GetIdentifiersByUserAsync(Ac { var command = new GetUserIdentifierCommand(async innerCt => { - var identifier = await _identifierStore.GetAsync(context.ResourceTenantId, type, value, innerCt); + var identifier = await _identifierStore.GetAsync(context.ResourceTenant, type, value, innerCt); return identifier is null ? null : UserIdentifierMapper.ToDto(identifier); @@ -201,7 +201,7 @@ public async Task UserIdentifierExistsAsync(AccessContext context, UserIde { var command = new UserIdentifierExistsCommand(async innerCt => { - return await _identifierStore.ExistsAsync(context.ResourceTenantId, type, value, innerCt); + return await _identifierStore.ExistsAsync(context.ResourceTenant, type, value, innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -213,7 +213,7 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie { var userKey = context.GetTargetUserKey(); - await _identifierStore.CreateAsync(context.ResourceTenantId, + await _identifierStore.CreateAsync(context.ResourceTenant, new UserIdentifier { UserKey = userKey, @@ -236,7 +236,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (string.Equals(request.OldValue, request.NewValue, StringComparison.Ordinal)) throw new InvalidOperationException("identifier_value_unchanged"); - await _identifierStore.UpdateValueAsync(context.ResourceTenantId, request.Type, request.OldValue, request.NewValue, _clock.UtcNow, innerCt); + await _identifierStore.UpdateValueAsync(context.ResourceTenant, request.Type, request.OldValue, request.NewValue, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -248,7 +248,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar { var userKey = context.GetTargetUserKey(); - await _identifierStore.SetPrimaryAsync(context.ResourceTenantId, userKey, request.Type, request.Value, innerCt); + await _identifierStore.SetPrimaryAsync(context.ResourceTenant, userKey, request.Type, request.Value, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -260,7 +260,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr { var userKey = context.GetTargetUserKey(); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, userKey, innerCt); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); if (target is null) @@ -273,7 +273,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr if (otherLoginIdentifiers.Count == 0) throw new InvalidOperationException("cannot_unset_last_primary_login_identifier"); - await _identifierStore.UnsetPrimaryAsync(context.ResourceTenantId, userKey, target.Type, target.Value, innerCt); + await _identifierStore.UnsetPrimaryAsync(context.ResourceTenant, userKey, target.Type, target.Value, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -283,7 +283,7 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde { var command = new VerifyUserIdentifierCommand(async innerCt => { - await _identifierStore.MarkVerifiedAsync(context.ResourceTenantId, request.Type, request.Value, _clock.UtcNow, innerCt); + await _identifierStore.MarkVerifiedAsync(context.ResourceTenant, request.Type, request.Value, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -295,7 +295,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde { var targetUserKey = context.GetTargetUserKey(); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, targetUserKey, innerCt); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, targetUserKey, innerCt); var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); if (target is null) @@ -308,7 +308,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde if (target.IsPrimary) throw new InvalidOperationException("cannot_delete_primary_identifier"); - await _identifierStore.DeleteAsync(context.ResourceTenantId, request.Type, request.Value, request.Mode, _clock.UtcNow, innerCt); + await _identifierStore.DeleteAsync(context.ResourceTenant, request.Type, request.Value, request.Mode, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -321,27 +321,27 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque var targetUserKey = context.GetTargetUserKey(); var now = _clock.UtcNow; - await _lifecycleStore.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); - await _identifierStore.DeleteByUserAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); - await _profileStore.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); + await _lifecycleStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + await _identifierStore.DeleteByUserAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + await _profileStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); foreach (var integration in _integrations) { - await integration.OnUserDeletedAsync(context.ResourceTenantId, targetUserKey, request.Mode, innerCt); + await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt); } }); await _accessOrchestrator.ExecuteAsync(context, command, ct); } - private async Task BuildUserViewAsync(string? tenantId, UserKey userKey, CancellationToken ct) + private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) { - var profile = await _profileStore.GetAsync(tenantId, userKey, ct); + var profile = await _profileStore.GetAsync(tenant, userKey, ct); if (profile is null || profile.IsDeleted) throw new InvalidOperationException("user_profile_not_found"); - var identifiers = await _identifierStore.GetByUserAsync(tenantId, userKey, ct); + var identifiers = await _identifierStore.GetByUserAsync(tenant, userKey, ct); var username = identifiers.FirstOrDefault(x => x.Type == UserIdentifierType.Username && x.IsPrimary); var primaryEmail = identifiers.FirstOrDefault(x => x.Type == UserIdentifierType.Email && x.IsPrimary); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 71a42be7..b2bac720 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -1,28 +1,29 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserIdentifierStore { - Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); + Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); - Task> GetByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task GetAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); + Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); - Task CreateAsync(string? tenantId, UserIdentifier identifier, CancellationToken ct = default); + Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default); - Task UpdateValueAsync(string? tenantId, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); + Task UpdateValueAsync(TenantKey tenant, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); - Task MarkVerifiedAsync(string? tenantId, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default); + Task MarkVerifiedAsync(TenantKey tenant, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default); - Task SetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); + Task SetPrimaryAsync(TenantKey tenant, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); - Task UnsetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); + Task UnsetPrimaryAsync(TenantKey tenant, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + Task DeleteAsync(TenantKey tenant, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); - Task DeleteByUserAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs index 44507c80..38b0c42f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -1,23 +1,23 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserLifecycleStore { - public interface IUserLifecycleStore - { - Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task> QueryAsync(string? tenantId, UserLifecycleQuery query, CancellationToken ct = default); + Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default); - Task CreateAsync(string? tenantId, UserLifecycle lifecycle, CancellationToken ct = default); + Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default); - Task ChangeStatusAsync(string? tenantId, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default); + Task ChangeStatusAsync(TenantKey tenant, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default); - Task ChangeSecurityStampAsync(string? tenantId, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default); + Task ChangeSecurityStampAsync(TenantKey tenant, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); - } + Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index 78502085..8c34959d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,19 +1,20 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserProfileStore { - Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task> QueryAsync(string? tenantId, UserProfileQuery query, CancellationToken ct = default); + Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default); - Task CreateAsync(string? tenantId, UserProfile profile, CancellationToken ct = default); + Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default); - Task UpdateAsync(string? tenantId, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default); + Task UpdateAsync(TenantKey tenant, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs index 8c02456a..e77f70c9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs @@ -1,32 +1,32 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Users.Reference +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserRuntimeStore : IUserRuntimeStateProvider { - internal sealed class UserRuntimeStore : IUserRuntimeStateProvider - { - private readonly IUserLifecycleStore _lifecycleStore; + private readonly IUserLifecycleStore _lifecycleStore; - public UserRuntimeStore(IUserLifecycleStore lifecycleStore) - { - _lifecycleStore = lifecycleStore; - } + public UserRuntimeStore(IUserLifecycleStore lifecycleStore) + { + _lifecycleStore = lifecycleStore; + } - public async Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - var lifecycle = await _lifecycleStore.GetAsync(tenantId, userKey, ct); + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var lifecycle = await _lifecycleStore.GetAsync(tenant, userKey, ct); - if (lifecycle is null) - return null; + if (lifecycle is null) + return null; - return new UserRuntimeRecord - { - UserKey = lifecycle.UserKey, - IsActive = lifecycle.Status == UserStatus.Active, - IsDeleted = lifecycle.IsDeleted, - Exists = true - }; - } + return new UserRuntimeRecord + { + UserKey = lifecycle.UserKey, + IsActive = lifecycle.Status == UserStatus.Active, + IsDeleted = lifecycle.IsDeleted, + Exists = true + }; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs index b4a93486..b3cda978 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs @@ -1,8 +1,7 @@ -namespace CodeBeam.UltimateAuth.Users +namespace CodeBeam.UltimateAuth.Users; + +public interface IUser { - public interface IUser - { - TUserId UserId { get; } - bool IsActive { get; } - } + TUserId UserId { get; } + bool IsActive { get; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs index 130390a5..068a54d2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.Abstractions; @@ -9,7 +10,7 @@ namespace CodeBeam.UltimateAuth.Users.Abstractions; /// public interface IUserLifecycleIntegration { - Task OnUserCreatedAsync(string? tenantId, UserKey userKey, object request, CancellationToken ct = default); + Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, CancellationToken ct = default); - Task OnUserDeletedAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default); + Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs index f001bef8..b819f744 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs @@ -1,7 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityStateProvider { - public interface IUserSecurityStateProvider - { - Task GetAsync(string? tenantId, TUserId userId, CancellationToken ct = default); - } + Task GetAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs index 78922083..1fdc364e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs @@ -5,86 +5,84 @@ using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Tests.Unit.Fake; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Tests.Unit.Client +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class BlazorServerSessionCoordinatorTests { - public sealed class BlazorServerSessionCoordinatorTests - { - //[Fact] - //public async Task StartAsync_MarksStarted_AndAutomaticRefresh() - //{ - // var diagnostics = new UAuthClientDiagnostics(); - // var client = new FakeFlowClient(RefreshOutcome.NoOp); - // var nav = new TestNavigationManager(); - - // var options = Options.Create(new UAuthClientOptions - // { - // Refresh = { Interval = TimeSpan.FromMilliseconds(10) } - // }); - - // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), - // nav, - // options, - // diagnostics); - - // await coordinator.StartAsync(); - // await Task.Delay(30); - // await coordinator.StopAsync(); - - // Assert.Equal(1, diagnostics.StartCount); - // Assert.True(diagnostics.AutomaticRefreshCount >= 1); - //} - - //[Fact] - //public async Task ReauthRequired_ShouldTerminateAndNavigate() - //{ - // var diagnostics = new UAuthClientDiagnostics(); - // var client = new FakeFlowClient(RefreshOutcome.ReauthRequired); - // var nav = new TestNavigationManager(); - - // var options = Options.Create(new UAuthClientOptions - // { - // Refresh = { Interval = TimeSpan.FromMilliseconds(5) }, - // Reauth = - // { - // Behavior = ReauthBehavior.RedirectToLogin, - // LoginPath = "/login" - // } - // }); - - // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), - // nav, - // options, - // diagnostics); - - // await coordinator.StartAsync(); - // await Task.Delay(20); - - // Assert.True(diagnostics.IsTerminated); - // Assert.Equal(CoordinatorTerminationReason.ReauthRequired, diagnostics.TerminationReason); - // Assert.Equal("/login", nav.LastNavigatedTo); - //} - - //[Fact] - //public async Task StopAsync_ShouldMarkStopped() - //{ - // var diagnostics = new UAuthClientDiagnostics(); - // var client = new FakeFlowClient(); - // var nav = new TestNavigationManager(); - - // var options = Options.Create(new UAuthClientOptions()); - - // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), - // nav, - // options, - // diagnostics); - - // await coordinator.StartAsync(); - // await coordinator.StopAsync(); - - // Assert.Equal(1, diagnostics.StopCount); - //} - } + //[Fact] + //public async Task StartAsync_MarksStarted_AndAutomaticRefresh() + //{ + // var diagnostics = new UAuthClientDiagnostics(); + // var client = new FakeFlowClient(RefreshOutcome.NoOp); + // var nav = new TestNavigationManager(); + + // var options = Options.Create(new UAuthClientOptions + // { + // Refresh = { Interval = TimeSpan.FromMilliseconds(10) } + // }); + + // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), + // nav, + // options, + // diagnostics); + + // await coordinator.StartAsync(); + // await Task.Delay(30); + // await coordinator.StopAsync(); + + // Assert.Equal(1, diagnostics.StartCount); + // Assert.True(diagnostics.AutomaticRefreshCount >= 1); + //} + + //[Fact] + //public async Task ReauthRequired_ShouldTerminateAndNavigate() + //{ + // var diagnostics = new UAuthClientDiagnostics(); + // var client = new FakeFlowClient(RefreshOutcome.ReauthRequired); + // var nav = new TestNavigationManager(); + + // var options = Options.Create(new UAuthClientOptions + // { + // Refresh = { Interval = TimeSpan.FromMilliseconds(5) }, + // Reauth = + // { + // Behavior = ReauthBehavior.RedirectToLogin, + // LoginPath = "/login" + // } + // }); + + // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), + // nav, + // options, + // diagnostics); + + // await coordinator.StartAsync(); + // await Task.Delay(20); + + // Assert.True(diagnostics.IsTerminated); + // Assert.Equal(CoordinatorTerminationReason.ReauthRequired, diagnostics.TerminationReason); + // Assert.Equal("/login", nav.LastNavigatedTo); + //} + + //[Fact] + //public async Task StopAsync_ShouldMarkStopped() + //{ + // var diagnostics = new UAuthClientDiagnostics(); + // var client = new FakeFlowClient(); + // var nav = new TestNavigationManager(); + + // var options = Options.Create(new UAuthClientOptions()); + + // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), + // nav, + // options, + // diagnostics); + + // await coordinator.StartAsync(); + // await coordinator.StopAsync(); + + // Assert.Equal(1, diagnostics.StopCount); + //} } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs index 054bd22c..0d2182a8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs @@ -3,104 +3,103 @@ using CodeBeam.UltimateAuth.Client.Diagnostics; using Xunit; -namespace CodeBeam.UltimateAuth.Tests.Unit.Client +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClientDiagnosticsTests { - 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() { - [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.MarkRefreshUnknown(); - - Assert.Equal(1, diagnostics.RefreshTouchedCount); - Assert.Equal(1, diagnostics.RefreshNoOpCount); - Assert.Equal(1, diagnostics.RefreshReauthRequiredCount); - Assert.Equal(1, diagnostics.RefreshUnknownCount); - } - - [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); - } + 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.MarkRefreshUnknown(); + + Assert.Equal(1, diagnostics.RefreshTouchedCount); + Assert.Equal(1, diagnostics.RefreshNoOpCount); + Assert.Equal(1, diagnostics.RefreshReauthRequiredCount); + Assert.Equal(1, diagnostics.RefreshUnknownCount); + } + + [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/RefreshOutcomeParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs index e0725041..b073c2a8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs @@ -1,34 +1,32 @@ using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Core.Domain; -using Xunit; -namespace CodeBeam.UltimateAuth.Tests.Unit +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class RefreshOutcomeParserTests { - 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) { - [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); + var result = RefreshOutcomeParser.Parse(input); - Assert.Equal(expected, result); - } + 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); + [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.None, result); - } + Assert.Equal(RefreshOutcome.None, result); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index ad919a4e..bf98b03d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -2,115 +2,115 @@ 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.Tokens.InMemory; using System.Text; -namespace CodeBeam.UltimateAuth.Tests.Unit.Core +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class RefreshTokenValidatorTests { - public sealed class RefreshTokenValidatorTests + private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; + + private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStore store) { - private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; + return new UAuthRefreshTokenValidator(store, CreateHasher()); + } - private static DefaultRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStore store) - { - return new DefaultRefreshTokenValidator(store, CreateHasher()); - } + private static ITokenHasher CreateHasher() + { + return new HmacSha256TokenHasher(Encoding.UTF8.GetBytes("unit-test-secret-key")); + } - private static ITokenHasher CreateHasher() - { - return new HmacSha256TokenHasher(Encoding.UTF8.GetBytes("unit-test-secret-key")); - } + [Fact] + public async Task Invalid_When_Token_Not_Found() + { + var store = new InMemoryRefreshTokenStore(); + var validator = CreateValidator(store); - [Fact] - public async Task Invalid_When_Token_Not_Found() - { - var store = new InMemoryRefreshTokenStore(); - var validator = CreateValidator(store); - - var result = await validator.ValidateAsync( - new RefreshTokenValidationContext - { - TenantId = null, - RefreshToken = "non-existing", - Now = DateTimeOffset.UtcNow, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), - }); - - Assert.False(result.IsValid); - Assert.False(result.IsReuseDetected); - } - - [Fact] - public async Task Reuse_Detected_When_Token_is_Revoked() - { - var store = new InMemoryRefreshTokenStore(); - var hasher = CreateHasher(); - var validator = CreateValidator(store); + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = "non-existing", + Now = DateTimeOffset.UtcNow, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + }); - var now = DateTimeOffset.UtcNow; + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } - var rawToken = "refresh-token-1"; - var hash = hasher.Hash(rawToken); + [Fact] + public async Task Reuse_Detected_When_Token_is_Revoked() + { + var store = new InMemoryRefreshTokenStore(); + var hasher = CreateHasher(); + var validator = CreateValidator(store); + + var now = DateTimeOffset.UtcNow; + + var rawToken = "refresh-token-1"; + var hash = hasher.Hash(rawToken); - await store.StoreAsync(null, new StoredRefreshToken + await store.StoreAsync(TenantKey.Single, new StoredRefreshToken + { + Tenant = TenantKey.Single, + TokenHash = hash, + UserKey = UserKey.FromString("user-1"), + SessionId = TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), + ChainId = SessionChainId.New(), + IssuedAt = now.AddMinutes(-5), + ExpiresAt = now.AddMinutes(5), + RevokedAt = now + }); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext { - TenantId = null, - TokenHash = hash, - UserKey = UserKey.FromString("user-1"), - SessionId = TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), - ChainId = SessionChainId.New(), - IssuedAt = now.AddMinutes(-5), - ExpiresAt = now.AddMinutes(5), - RevokedAt = now + Tenant = TenantKey.Single, + RefreshToken = rawToken, + Now = now, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), }); - var result = await validator.ValidateAsync( - new RefreshTokenValidationContext - { - TenantId = null, - RefreshToken = rawToken, - Now = now, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), - }); - - Assert.False(result.IsValid); - Assert.True(result.IsReuseDetected); - } - - [Fact] - public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() - { - var store = new InMemoryRefreshTokenStore(); - var validator = CreateValidator(store); + Assert.False(result.IsValid); + Assert.True(result.IsReuseDetected); + } - var now = DateTimeOffset.UtcNow; + [Fact] + public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() + { + var store = new InMemoryRefreshTokenStore(); + var validator = CreateValidator(store); + + var now = DateTimeOffset.UtcNow; - await store.StoreAsync(null, new StoredRefreshToken + await store.StoreAsync(TenantKey.Single, new StoredRefreshToken + { + Tenant = TenantKey.Single, + TokenHash = "hash-2", + UserKey = UserKey.FromString("user-1"), + SessionId = TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), + ChainId = SessionChainId.New(), + IssuedAt = now, + ExpiresAt = now.AddMinutes(10) + }); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext { - TenantId = null, - TokenHash = "hash-2", - UserKey = UserKey.FromString("user-1"), - SessionId = TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), - ChainId = SessionChainId.New(), - IssuedAt = now, - ExpiresAt = now.AddMinutes(10) + Tenant = TenantKey.Single, + RefreshToken = "hash-2", + ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), + Now = now, + Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), }); - var result = await validator.ValidateAsync( - new RefreshTokenValidationContext - { - TenantId = null, - RefreshToken = "hash-2", - ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), - Now = now, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), - }); - - Assert.False(result.IsValid); - Assert.False(result.IsReuseDetected); - } - + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); } + } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs index 605ff14e..8c010928 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -17,7 +18,7 @@ public void New_chain_has_expected_initial_state() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - tenantId: null, + tenant: TenantKey.Single, userKey: UserKey.FromString("user-1"), securityVersion: 0, ClaimsSnapshot.Empty); @@ -33,7 +34,7 @@ public void Rotating_chain_sets_active_session_and_increments_rotation() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - null, + TenantKey.Single, UserKey.FromString("user-1"), 0, ClaimsSnapshot.Empty); @@ -52,7 +53,7 @@ public void Multiple_rotations_increment_rotation_count() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - null, + TenantKey.Single, UserKey.FromString("user-1"), 0, ClaimsSnapshot.Empty); @@ -72,7 +73,7 @@ public void Revoked_chain_does_not_rotate() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - null, + TenantKey.Single, UserKey.FromString("user-1"), 0, ClaimsSnapshot.Empty); @@ -92,7 +93,7 @@ public void Revoking_chain_sets_revocation_fields() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - null, + TenantKey.Single, UserKey.FromString("user-1"), 0, ClaimsSnapshot.Empty); @@ -111,7 +112,7 @@ public void Revoking_already_revoked_chain_is_idempotent() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - null, + TenantKey.Single, UserKey.FromString("user-1"), 0, ClaimsSnapshot.Empty); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index 7548b28d..6a858508 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -1,5 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; -using Xunit; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -16,7 +16,7 @@ public void Revoke_marks_session_as_revoked() var session = UAuthSession.Create( sessionId: sessionId, - tenantId: null, + tenant: TenantKey.Single, userKey: UserKey.FromString("user-1"), chainId: SessionChainId.New(), now, @@ -40,7 +40,7 @@ public void Revoking_twice_returns_same_instance() var session = UAuthSession.Create( sessionId, - null, + TenantKey.Single, UserKey.FromString("user-1"), SessionChainId.New(), now, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs index a5d74ae8..a887a0df 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs @@ -3,100 +3,99 @@ using System.Globalization; using System.Text.Json; -namespace CodeBeam.UltimateAuth.Tests.Unit +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UserIdConverterTests { - public sealed class UserIdConverterTests + [Fact] + public void UserKey_Roundtrip_Should_Preserve_Value() { - [Fact] - public void UserKey_Roundtrip_Should_Preserve_Value() - { - var key = UserKey.New(); - var converter = new UAuthUserIdConverter(); - - var str = converter.ToString(key); - var parsed = converter.FromString(str); + var key = UserKey.New(); + var converter = new UAuthUserIdConverter(); - Assert.Equal(key, parsed); - } + var str = converter.ToCanonicalString(key); + var parsed = converter.FromString(str); - [Fact] - public void Guid_Roundtrip_Should_Work() - { - var id = Guid.NewGuid(); - var converter = new UAuthUserIdConverter(); + Assert.Equal(key, parsed); + } - var str = converter.ToString(id); - var parsed = converter.FromString(str); + [Fact] + public void Guid_Roundtrip_Should_Work() + { + var id = Guid.NewGuid(); + var converter = new UAuthUserIdConverter(); - Assert.Equal(id, parsed); - } + var str = converter.ToCanonicalString(id); + var parsed = converter.FromString(str); - [Fact] - public void String_Roundtrip_Should_Work() - { - var id = "user_123"; - var converter = new UAuthUserIdConverter(); + Assert.Equal(id, parsed); + } - var str = converter.ToString(id); - var parsed = converter.FromString(str); + [Fact] + public void String_Roundtrip_Should_Work() + { + var id = "user_123"; + var converter = new UAuthUserIdConverter(); - Assert.Equal(id, parsed); - } + var str = converter.ToCanonicalString(id); + var parsed = converter.FromString(str); - [Fact] - public void Int_Should_Use_Invariant_Culture() - { - var id = 1234; - var converter = new UAuthUserIdConverter(); + Assert.Equal(id, parsed); + } - var str = converter.ToString(id); + [Fact] + public void Int_Should_Use_Invariant_Culture() + { + var id = 1234; + var converter = new UAuthUserIdConverter(); - Assert.Equal(id.ToString(CultureInfo.InvariantCulture), str); - } + var str = converter.ToCanonicalString(id); - [Fact] - public void Long_Roundtrip_Should_Work() - { - var id = 9_223_372_036_854_775_000L; - var converter = new UAuthUserIdConverter(); + Assert.Equal(id.ToString(CultureInfo.InvariantCulture), str); + } - var str = converter.ToString(id); - var parsed = converter.FromString(str); + [Fact] + public void Long_Roundtrip_Should_Work() + { + var id = 9_223_372_036_854_775_000L; + var converter = new UAuthUserIdConverter(); - Assert.Equal(id, parsed); - } + var str = converter.ToCanonicalString(id); + var parsed = converter.FromString(str); - [Fact] - public void Double_UserId_Should_Throw() - { - var converter = new UAuthUserIdConverter(); + Assert.Equal(id, parsed); + } - Assert.ThrowsAny(() => converter.ToString(12.34)); - } + [Fact] + public void Double_UserId_Should_Throw() + { + var converter = new UAuthUserIdConverter(); - private sealed class CustomUserId - { - public string Value { get; set; } = "x"; - } + Assert.ThrowsAny(() => converter.ToCanonicalString(12.34)); + } - [Fact] - public void Custom_UserId_Should_Fail() - { - var converter = new UAuthUserIdConverter(); + private sealed class CustomUserId + { + public string Value { get; set; } = "x"; + } - Assert.ThrowsAny(() => converter.ToString(new CustomUserId())); - } + [Fact] + public void Custom_UserId_Should_Fail() + { + var converter = new UAuthUserIdConverter(); - [Fact] - public void UserKey_Json_Serialization_Should_Be_String() - { - var key = UserKey.New(); + Assert.ThrowsAny(() => converter.ToCanonicalString(new CustomUserId())); + } - var json = JsonSerializer.Serialize(key); - var roundtrip = JsonSerializer.Deserialize(json); + [Fact] + public void UserKey_Json_Serialization_Should_Be_String() + { + var key = UserKey.New(); - Assert.Equal(key, roundtrip); - } + var json = JsonSerializer.Serialize(key); + var roundtrip = JsonSerializer.Deserialize(json); + Assert.Equal(key, roundtrip); } + } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs index 3f8f07e0..6ab9d80f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs @@ -1,95 +1,94 @@ using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; -namespace CodeBeam.UltimateAuth.Tests.Unit +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class CredentialUserMappingBuilderTests { - public class CredentialUserMappingBuilderTests + private sealed class ConventionUser { - private sealed class ConventionUser - { - public Guid Id { get; set; } - public string Email { get; set; } = default!; - public string PasswordHash { get; set; } = default!; - public long SecurityVersion { get; set; } - } + public Guid Id { get; set; } + public string Email { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public long SecurityVersion { get; set; } + } - private sealed class ExplicitUser - { - public Guid UserId { get; set; } - public string LoginName { get; set; } = default!; - public string PasswordHash { get; set; } = default!; - public long SecurityVersion { get; set; } - } + private sealed class ExplicitUser + { + public Guid UserId { get; set; } + public string LoginName { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public long SecurityVersion { get; set; } + } - private sealed class PlainPasswordUser - { - public Guid Id { get; set; } - public string Username { get; set; } = default!; - public string Password { get; set; } = default!; - public long SecurityVersion { get; set; } - } + private sealed class PlainPasswordUser + { + public Guid Id { get; set; } + public string Username { get; set; } = default!; + public string Password { get; set; } = default!; + public long SecurityVersion { get; set; } + } - [Fact] - public void Build_UsesConventions_WhenExplicitMappingIsNotProvided() + [Fact] + public void Build_UsesConventions_WhenExplicitMappingIsNotProvided() + { + var options = new CredentialUserMappingOptions(); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ConventionUser { - var options = new CredentialUserMappingOptions(); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ConventionUser - { - Id = Guid.NewGuid(), - Email = "test@example.com", - PasswordHash = "hash", - SecurityVersion = 3 - }; + Id = Guid.NewGuid(), + Email = "test@example.com", + PasswordHash = "hash", + SecurityVersion = 3 + }; - Assert.Equal(user.Id, mapping.UserId(user)); - Assert.Equal(user.Email, mapping.Username(user)); - Assert.Equal(user.PasswordHash, mapping.PasswordHash(user)); - Assert.Equal(user.SecurityVersion, mapping.SecurityVersion(user)); - Assert.True(mapping.CanAuthenticate(user)); - } + Assert.Equal(user.Id, mapping.UserId(user)); + Assert.Equal(user.Email, mapping.Username(user)); + Assert.Equal(user.PasswordHash, mapping.PasswordHash(user)); + Assert.Equal(user.SecurityVersion, mapping.SecurityVersion(user)); + Assert.True(mapping.CanAuthenticate(user)); + } - [Fact] - public void Build_ExplicitMapping_OverridesConvention() + [Fact] + public void Build_ExplicitMapping_OverridesConvention() + { + var options = new CredentialUserMappingOptions(); + options.MapUsername(u => u.LoginName); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ExplicitUser { - var options = new CredentialUserMappingOptions(); - options.MapUsername(u => u.LoginName); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ExplicitUser - { - UserId = Guid.NewGuid(), - LoginName = "custom-login", - PasswordHash = "hash", - SecurityVersion = 1 - }; + UserId = Guid.NewGuid(), + LoginName = "custom-login", + PasswordHash = "hash", + SecurityVersion = 1 + }; - Assert.Equal("custom-login", mapping.Username(user)); - } + Assert.Equal("custom-login", mapping.Username(user)); + } - [Fact] - public void Build_DoesNotMap_PlainPassword_Property() - { - var options = new CredentialUserMappingOptions(); - var ex = Assert.Throws(() => CredentialUserMappingBuilder.Build(options)); + [Fact] + public void Build_DoesNotMap_PlainPassword_Property() + { + var options = new CredentialUserMappingOptions(); + var ex = Assert.Throws(() => CredentialUserMappingBuilder.Build(options)); - Assert.Contains("PasswordHash mapping is required", ex.Message); - } + Assert.Contains("PasswordHash mapping is required", ex.Message); + } - [Fact] - public void Build_Defaults_CanAuthenticate_ToTrue() + [Fact] + public void Build_Defaults_CanAuthenticate_ToTrue() + { + var options = new CredentialUserMappingOptions(); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ConventionUser { - var options = new CredentialUserMappingOptions(); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ConventionUser - { - Id = Guid.NewGuid(), - Email = "active@example.com", - PasswordHash = "hash", - SecurityVersion = 0 - }; + Id = Guid.NewGuid(), + Email = "active@example.com", + PasswordHash = "hash", + SecurityVersion = 0 + }; - var canAuthenticate = mapping.CanAuthenticate(user); - Assert.True(canAuthenticate); - } + var canAuthenticate = mapping.CanAuthenticate(user); + Assert.True(canAuthenticate); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index 5b6b633e..17c16dbb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -1,83 +1,80 @@ -using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using System.Security.Claims; -namespace CodeBeam.UltimateAuth.Tests.Unit -{ - internal sealed class FakeFlowClient : IFlowClient - { - private readonly Queue _outcomes; +namespace CodeBeam.UltimateAuth.Tests.Unit; - public FakeFlowClient(params RefreshOutcome[] outcomes) - { - _outcomes = new Queue(outcomes); - } +internal sealed class FakeFlowClient : IFlowClient +{ + private readonly Queue _outcomes; - public Task BeginPkceAsync(bool navigateToHubLogin = true) - { - throw new NotImplementedException(); - } + public FakeFlowClient(params RefreshOutcome[] outcomes) + { + _outcomes = new Queue(outcomes); + } - public Task BeginPkceAsync(string? returnUrl = null) - { - throw new NotImplementedException(); - } + public Task BeginPkceAsync(bool navigateToHubLogin = true) + { + throw new NotImplementedException(); + } - public Task CompletePkceLoginAsync(LoginRequest request) - { - throw new NotImplementedException(); - } + public Task BeginPkceAsync(string? returnUrl = null) + { + throw new NotImplementedException(); + } - public Task CompletePkceLoginAsync(PkceLoginRequest request) - { - throw new NotImplementedException(); - } + public Task CompletePkceLoginAsync(LoginRequest request) + { + throw new NotImplementedException(); + } - public Task GetCurrentPrincipalAsync() - { - throw new NotImplementedException(); - } + public Task CompletePkceLoginAsync(PkceLoginRequest request) + { + throw new NotImplementedException(); + } - public Task LoginAsync(LoginRequest request) - { - throw new NotImplementedException(); - } + public Task GetCurrentPrincipalAsync() + { + throw new NotImplementedException(); + } - public Task LogoutAsync() - { - throw new NotImplementedException(); - } + public Task LoginAsync(LoginRequest request) + { + throw new NotImplementedException(); + } - public Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string? returnUrl = null) - { - throw new NotImplementedException(); - } + public Task LogoutAsync() + { + throw new NotImplementedException(); + } - public Task ReauthAsync() - { - throw new NotImplementedException(); - } + public Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string? returnUrl = null) + { + throw new NotImplementedException(); + } - public Task RefreshAsync(bool isAuto = false) - { - var outcome = _outcomes.Count > 0 - ? _outcomes.Dequeue() - : RefreshOutcome.None; + public Task ReauthAsync() + { + throw new NotImplementedException(); + } - return Task.FromResult(new RefreshResult - { - Ok = true, - Outcome = outcome - }); - } + public Task RefreshAsync(bool isAuto = false) + { + var outcome = _outcomes.Count > 0 + ? _outcomes.Dequeue() + : RefreshOutcome.None; - public Task ValidateAsync() + return Task.FromResult(new RefreshResult { - throw new NotImplementedException(); - } + Ok = true, + Outcome = outcome + }); } + 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 index aa80e271..a0cd5b50 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeNavigationManager.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeNavigationManager.cs @@ -1,19 +1,18 @@ using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Tests.Unit.Fake +namespace CodeBeam.UltimateAuth.Tests.Unit; + +internal sealed class TestNavigationManager : NavigationManager { - internal sealed class TestNavigationManager : NavigationManager - { - public string? LastNavigatedTo { get; private set; } + public string? LastNavigatedTo { get; private set; } - public TestNavigationManager() - { - Initialize("http://localhost/", "http://localhost/"); - } + public TestNavigationManager() + { + Initialize("http://localhost/", "http://localhost/"); + } - protected override void NavigateToCore(string uri, bool forceLoad) - { - LastNavigatedTo = uri; - } + protected override void NavigateToCore(string uri, bool forceLoad) + { + LastNavigatedTo = uri; } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs index ff7c37b5..3c1c08f5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -1,34 +1,33 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies; -namespace CodeBeam.UltimateAuth.Tests.Unit.Policies +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ActionTextTests { - public class ActionTextTests + [Theory] + [InlineData("users.profile.get.admin", true)] + [InlineData("users.profile.get.self", false)] + [InlineData("users.profile.get", false)] + public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) { - [Theory] - [InlineData("users.profile.get.admin", true)] - [InlineData("users.profile.get.self", false)] - [InlineData("users.profile.get", false)] - public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) - { - var context = new AccessContext { Action = action }; - var policy = new RequireAdminPolicy(); + var context = new AccessContext { Action = action }; + var policy = new RequireAdminPolicy(); - Assert.Equal(expected, policy.AppliesTo(context)); - } + Assert.Equal(expected, policy.AppliesTo(context)); + } - [Fact] - public void RequireAdminPolicy_DoesNotMatch_Substrings() + [Fact] + public void RequireAdminPolicy_DoesNotMatch_Substrings() + { + var context = new AccessContext { - var context = new AccessContext - { - Action = "users.profile.get.administrator" - }; - - var policy = new RequireAdminPolicy(); + Action = "users.profile.get.administrator" + }; - Assert.False(policy.AppliesTo(context)); - } + var policy = new RequireAdminPolicy(); + Assert.False(policy.AppliesTo(context)); } + } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs index 02881234..9c27d724 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs @@ -1,40 +1,38 @@ -using System; -using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Tests.Unit.Server -{ - public class EffectiveAuthModeResolverTests - { - private readonly DefaultEffectiveAuthModeResolver _resolver = new(); +namespace CodeBeam.UltimateAuth.Tests.Unit; - [Fact] - public void ConfiguredMode_Wins_Over_ClientProfile() - { - var mode = _resolver.Resolve( - configuredMode: UAuthMode.PureJwt, - clientProfile: UAuthClientProfile.BlazorWasm, - flowType: AuthFlowType.Login); +public class EffectiveAuthModeResolverTests +{ + private readonly EffectiveAuthModeResolver _resolver = new(); - Assert.Equal(UAuthMode.PureJwt, mode); - } + [Fact] + public void ConfiguredMode_Wins_Over_ClientProfile() + { + var mode = _resolver.Resolve( + configuredMode: UAuthMode.PureJwt, + clientProfile: UAuthClientProfile.BlazorWasm, + flowType: AuthFlowType.Login); - [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( - configuredMode: null, - clientProfile: profile, - flowType: AuthFlowType.Login); + Assert.Equal(UAuthMode.PureJwt, mode); + } - Assert.Equal(expected, mode); - } + [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( + configuredMode: null, + 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 index d16e2ee0..17df3e17 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs @@ -5,142 +5,141 @@ using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Tests.Unit.Server +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EffectiveServerOptionsProviderTests { - public class EffectiveServerOptionsProviderTests + [Fact] + public void Original_Options_Are_Not_Mutated() { - [Fact] - public void Original_Options_Are_Not_Mutated() + var baseOptions = new UAuthServerOptions { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; + Mode = UAuthMode.Hybrid + }; - var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); + var effective = provider.GetEffective( + ctx, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); - effective.Options.Tokens.AccessTokenLifetime = TimeSpan.FromSeconds(10); + effective.Options.Tokens.AccessTokenLifetime = TimeSpan.FromSeconds(10); - Assert.NotEqual( - baseOptions.Tokens.AccessTokenLifetime, - effective.Options.Tokens.AccessTokenLifetime - ); - } + Assert.NotEqual( + baseOptions.Tokens.AccessTokenLifetime, + effective.Options.Tokens.AccessTokenLifetime + ); + } - [Fact] - public void EffectiveMode_Comes_From_ModeResolver() + [Fact] + public void EffectiveMode_Comes_From_ModeResolver() + { + var baseOptions = new UAuthServerOptions { - var baseOptions = new UAuthServerOptions - { - Mode = null // Not specified - }; + Mode = null // Not specified + }; - var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.Api); + var effective = provider.GetEffective( + ctx, + AuthFlowType.Login, + UAuthClientProfile.Api); - Assert.Equal(UAuthMode.PureJwt, effective.Mode); - } + Assert.Equal(UAuthMode.PureJwt, effective.Mode); + } - [Fact] - public void Mode_Defaults_Are_Applied() + [Fact] + public void Mode_Defaults_Are_Applied() + { + var baseOptions = new UAuthServerOptions { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.PureOpaque - }; + Mode = UAuthMode.PureOpaque + }; - var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); + var effective = provider.GetEffective( + ctx, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); - Assert.True(effective.Options.Session.SlidingExpiration); - Assert.NotNull(effective.Options.Session.IdleTimeout); - } + Assert.True(effective.Options.Session.SlidingExpiration); + Assert.NotNull(effective.Options.Session.IdleTimeout); + } - [Fact] - public void ModeConfiguration_Overrides_Defaults() + [Fact] + public void ModeConfiguration_Overrides_Defaults() + { + var baseOptions = new UAuthServerOptions { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; - - baseOptions.ConfigureMode(UAuthMode.Hybrid, o => - { - o.Tokens.AccessTokenLifetime = TimeSpan.FromMinutes(1); - }); - - var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); - - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); - - Assert.Equal( - TimeSpan.FromMinutes(1), - effective.Options.Tokens.AccessTokenLifetime - ); - } - - [Fact] - public void Each_Call_Returns_New_EffectiveOptions_Instance() + Mode = UAuthMode.Hybrid + }; + + baseOptions.ConfigureMode(UAuthMode.Hybrid, o => { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; + o.Tokens.AccessTokenLifetime = TimeSpan.FromMinutes(1); + }); - var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); - var first = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); - var second = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var effective = provider.GetEffective( + ctx, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); - Assert.NotSame(first.Options, second.Options); - } + Assert.Equal( + TimeSpan.FromMinutes(1), + effective.Options.Tokens.AccessTokenLifetime + ); + } - // TODO: Discuss and enable - //[Fact] - //public void FlowType_Is_Passed_To_ModeResolver() - //{ - // var baseOptions = new UAuthServerOptions - // { - // Mode = null - // }; + [Fact] + public void Each_Call_Returns_New_EffectiveOptions_Instance() + { + var baseOptions = new UAuthServerOptions + { + Mode = UAuthMode.Hybrid + }; - // var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - // var ctx = new DefaultHttpContext(); + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = new DefaultHttpContext(); - // var login = provider.GetEffective( - // ctx, - // AuthFlowType.Login, - // UAuthClientProfile.Api); + var first = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var second = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); - // var api = provider.GetEffective( - // ctx, - // AuthFlowType.ApiAccess, - // UAuthClientProfile.Api); + Assert.NotSame(first.Options, second.Options); + } - // Assert.NotEqual(login.Mode, api.Mode); - //} + // 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/TestHelpers.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs index 46083ba0..bcfe4c34 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs @@ -2,13 +2,12 @@ using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Tests.Unit +namespace CodeBeam.UltimateAuth.Tests.Unit; + +internal static class TestHelpers { - internal static class TestHelpers + public static EffectiveServerOptionsProvider CreateEffectiveOptionsProvider(UAuthServerOptions options, IEffectiveAuthModeResolver? modeResolver = null) { - public static DefaultEffectiveServerOptionsProvider CreateEffectiveOptionsProvider(UAuthServerOptions options, IEffectiveAuthModeResolver? modeResolver = null) - { - return new DefaultEffectiveServerOptionsProvider(Options.Create(options), modeResolver ?? new DefaultEffectiveAuthModeResolver()); - } + return new EffectiveServerOptionsProvider(Options.Create(options), modeResolver ?? new EffectiveAuthModeResolver()); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs index ee19e828..4aa8dbc8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs @@ -1,18 +1,14 @@ using CodeBeam.UltimateAuth.Core.Domain; -using System; -using System.Collections.Generic; -using System.Text; -namespace CodeBeam.UltimateAuth.Tests.Unit +namespace CodeBeam.UltimateAuth.Tests.Unit; + +internal static class TestIds { - internal static class TestIds + public static AuthSessionId Session(string raw) { - public static AuthSessionId Session(string raw) - { - if (!AuthSessionId.TryCreate(raw, out var id)) - throw new InvalidOperationException($"Invalid test AuthSessionId: {raw}"); + if (!AuthSessionId.TryCreate(raw, out var id)) + throw new InvalidOperationException($"Invalid test AuthSessionId: {raw}"); - return id; - } + return id; } } From 3089e8e1afc17d5f97141e69f97fab7532cc351d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:15:10 +0300 Subject: [PATCH 30/50] Fail-Fast Strategy & Options Improvement (#17) * Fail-Fast Strategy & Options Improvement * Added Lockout Mechanism & Bind LoginOptions * Session & Token Options and Validators * Bind Login & Logout Events With Options * Improved PKCE and MultiTenant Options & Fixed Path Tenant Resolver Logic * Completed Core Options & Improved AuthFlowContext and AccessContext Creation Security * Change Mode Option to AllowedModes * Server Options Improvements * Complete Checking Server Options * Improved ServiceCollectionExtensions * Enhanced Redirect Options & Handling * Added ReturnUrl Resolving * Fix Absolute Uri Platform Differencies * Try Another Fix * Complete Client Options Draft * Added Auth Invariants --- .../Components/Pages/Home.razor | 2 - ...am.UltimateAuth.Sample.BlazorServer.csproj | 4 +- .../Components/Pages/Home.razor | 8 +- .../Components/Pages/Home.razor.cs | 208 +++++---- .../Program.cs | 5 +- .../Pages/Home.razor | 6 +- .../Program.cs | 2 +- .../Components/UALoginForm.razor | 1 - .../Components/UALoginForm.razor.cs | 4 +- .../Contracts/TenantTransport.cs | 8 + .../Extensions/ServiceCollectionExtensions.cs | 61 +-- .../BlazorServerSessionCoordinator.cs | 9 +- .../Infrastructure/ClientLoginCapabilities.cs | 14 + .../Infrastructure/UAuthRequestClient.cs | 15 +- .../Infrastructure/UAuthUrlBuilder.cs | 26 +- .../Options/UAuthClientAutoRefreshOptions.cs | 26 ++ .../Options/UAuthClientEndpointOptions.cs | 18 + .../Options/UAuthClientLoginFlowOptions.cs | 25 ++ .../Options/UAuthClientMultiTenantOptions.cs | 22 + .../Options/UAuthClientOptions.cs | 72 +-- ....cs => UAuthClientPkceLoginFlowOptions.cs} | 2 +- .../Options/UAuthClientReauthOptions.cs | 10 + .../Options/UAuthOptionsPostConfigure.cs | 9 +- .../UAuthClientEndpointOptionsValidator.cs | 26 ++ .../Validators/UAuthClientOptionsValidator.cs | 18 + .../ProductInfo/UAuthClientProductInfo.cs | 9 - .../IUAuthClientProductInfoProvider.cs | 6 + .../Runtime/UAuthClientProductInfo.cs | 15 + .../Runtime/UAuthClientProductInfoProvider.cs | 25 ++ .../Services/IFlowClient.cs | 2 +- .../Services/UAuthAuthorizationClient.cs | 18 +- .../Services/UAuthCredentialClient.cs | 2 +- .../Services/UAuthFlowClient.cs | 67 ++- .../Services/UAuthUserClient.cs | 26 +- .../Services/UAuthUserIdentifierClient.cs | 45 +- .../Authority/IAuthorityInvariant.cs | 1 + .../Issuers/IOpaqueTokenGenerator.cs | 3 +- .../Abstractions/Issuers/ISessionIssuer.cs | 2 +- .../Stores/ISessionStoreKernel.cs | 3 +- .../Contracts/Authority/AccessContext.cs | 36 +- .../Contracts/Authority/AuthContext.cs | 3 + .../Contracts/Logout/LogoutReason.cs | 10 + .../Session/AuthenticatedSessionContext.cs | 1 + .../Session/SessionRotationContext.cs | 1 + .../Domain/Hub/HubFlowArtifact.cs | 2 +- .../Domain/Pkce/AuthArtifact.cs | 7 +- .../Domain/Pkce/HubLoginArtifact.cs | 5 +- .../Domain/Principals/ReauthBehavior.cs | 2 +- .../Events/SessionCreatedContext.cs | 47 -- .../Events/SessionRefreshedContext.cs | 59 --- .../Events/SessionRevokedContext.cs | 55 --- .../Events/UAuthEventDispatcher.cs | 20 +- .../Events/UAuthEvents.cs | 30 +- .../Events/UserLoggedInContext.cs | 37 +- .../Events/UserLoggedOutContext.cs | 44 +- .../Extensions/ClaimsSnapshotExtensions.cs | 19 + .../Extensions/ServiceCollectionExtensions.cs | 77 ++-- .../Authority/DeviceRequiredInvariant.cs | 35 ++ ...nvariant.cs => TenantResolvedInvariant.cs} | 7 +- .../Infrastructure/UserKeyJsonConverter.cs | 3 + .../MultiTenancy/PathTenantResolver.cs | 22 +- .../CoreConfigurationIntentDetector.cs | 29 ++ .../Options/UAuthLoginOptions.cs | 22 +- .../Options/UAuthMultiTenantOptions.cs | 24 +- .../UAuthMultiTenantOptionsValidator.cs | 31 -- .../Options/UAuthOptions.cs | 22 +- .../Options/UAuthOptionsPostConfigureGuard.cs | 38 ++ .../Options/UAuthPkceOptions.cs | 6 +- .../Options/UAuthSessionOptions.cs | 41 +- .../Options/UAuthSessionOptionsValidator.cs | 99 ----- .../Options/UAuthTokenOptions.cs | 1 - .../Validators/UAuthLoginOptionsValidator.cs | 24 + .../UAuthMultiTenantOptionsValidator.cs | 42 ++ .../{ => Validators}/UAuthOptionsValidator.cs | 6 + .../UAuthPkceOptionsValidator.cs | 0 .../UAuthSessionOptionsValidator.cs | 97 ++++ .../UAuthTokenOptionsValidator.cs | 28 +- .../Runtime/DirectCoreConfigurationMarker.cs | 15 + .../Runtime/IUAuthRuntimeMarker.cs | 9 + .../Runtime/UAuthProductInfo.cs | 7 +- .../Runtime/UAuthProductInfoProvider.cs | 3 - .../Auth/Context/AccessContextFactory.cs | 39 +- .../Auth/Context/AuthFlowContext.cs | 8 +- .../Auth/Context/AuthFlowContextFactory.cs | 54 ++- .../Auth/Context/IAuthFlowContextFactory.cs | 2 + .../Auth/EffectiveServerOptionsProvider.cs | 14 +- .../Auth/EffectiveUAuthServerOptions.cs | 2 +- ...AuthResponseOptionsModeTemplateResolver.cs | 58 ++- .../Auth/Response/AuthResponseResolver.cs | 37 +- .../ClientProfileAuthResponseAdapter.cs | 34 +- .../Response/EffectiveAuthModeResolver.cs | 5 +- .../Auth/Response/EffectiveAuthResponse.cs | 26 +- .../EffectiveLoginRedirectResponse.cs | 13 - .../Response/EffectiveRedirectResponse.cs | 52 +++ .../Response/IEffectiveAuthModeResolver.cs | 2 +- .../UAuthAuthenticationHandler.cs | 2 +- .../AddUltimateAuthServerExtensions.cs | 32 +- .../Endpoints/LoginEndpointHandler.cs | 57 +-- .../Endpoints/LogoutEndpointHandler.cs | 31 +- .../Endpoints/PkceEndpointHandler.cs | 27 +- .../Endpoints/UAuthEndpointRegistrar.cs | 31 +- .../Extensions/AuthFlowContextExtensions.cs | 19 +- .../EndpointRouteBuilderExtensions.cs | 13 +- .../HttpContextReturnUrlExtensions.cs | 21 + .../Extensions/ServiceCollectionExtensions.cs | 98 ++-- .../Flows/Login/LoginAuthority.cs | 23 +- .../Flows/Login/LoginOrchestrator.cs | 110 +++-- .../Flows/Pkce/PkceAuthorizationArtifact.cs | 3 +- .../Flows/Pkce/PkceAuthorizationValidator.cs | 8 - .../Infrastructure/AuthRedirectResolver.cs | 63 --- .../Cookies/IUAuthCookieManager.cs | 2 +- .../Cookies/IUAuthCookiePolicyBuilder.cs | 2 +- .../Cookies/UAuthCookieManager.cs | 2 +- .../Cookies/UAuthCookiePolicyBuilder.cs | 8 +- .../AspNetCore/TransportCredentialResolver.cs | 2 +- .../Credentials/CredentialResponseWriter.cs | 1 - .../DevelopmentJwtSigningKeyProvider.cs | 1 - .../Issuers/UAuthSessionIssuer.cs | 15 +- .../Issuers/UAuthTokenIssuer.cs | 15 +- .../Infrastructure/OpaqueTokenGenerator.cs | 15 +- .../Orchestrator/RevokeSessionCommand.cs | 7 +- .../Redirect/AuthRedirectResolver.cs | 83 ++++ .../Redirect/ClientBaseAdressResolver.cs | 53 +++ .../ConfiguredClientBaseAddressProvider.cs | 18 + .../Redirect/IAuthRedirectResolver.cs | 11 + .../Redirect/IClientBaseAddressProvider.cs | 9 + .../IFallbackClientBaseAddressProvider.cs | 5 + .../OriginHeaderBaseAddressProvider.cs | 21 + .../Redirect/RedirectDecision.cs | 23 + .../RefererHeaderBaseAddressProvider.cs | 21 + .../RequestHostBaseAddressProvider.cs | 13 + .../Infrastructure/Redirect/ReturnUrlInfo.cs | 28 ++ .../Infrastructure/Redirect/ReturnUrlKind.cs | 8 + .../Redirect/ReturnUrlParser.cs | 29 ++ .../SessionId/BearerSessionIdResolver.cs | 2 +- .../SessionId/CompositeSessionIdResolver.cs | 27 +- .../SessionId/CookieSessionIdResolver.cs | 2 +- .../SessionId/HeaderSessionIdResolver.cs | 2 +- .../SessionId/IInnerSessionIdResolver.cs | 2 +- .../SessionId/QuerySessionIdResolver.cs | 2 +- .../Infrastructure/UrlComposer.cs | 21 + .../Middlewares/TenantMiddleware.cs | 12 +- .../MultiTenancy/UAuthTenantContextFactory.cs | 4 +- .../MultiTenancy/UAuthTenantResolver.cs | 3 +- .../Options/CredentialResponseOptions.cs | 29 +- .../Options/Defaults/ConfigureDefaults.cs | 14 +- .../IEffectiveServerOptionsProvider.cs | 2 + .../Options/LoginRedirectOptions.cs | 14 +- ...Options.cs => UAuthCookiePolicyOptions.cs} | 16 +- .../Options/UAuthDiagnosticsOptions.cs | 1 - .../Options/UAuthHubServerOptions.cs | 1 - ...icy.cs => UAuthPrimaryCredentialPolicy.cs} | 4 +- ...onseOptions.cs => UAuthResponseOptions.cs} | 5 +- .../Options/UAuthServerEndpointOptions.cs | 37 ++ .../Options/UAuthServerOptions.cs | 156 +++---- .../Options/UAuthServerOptionsValidator.cs | 42 -- .../Options/UAuthSessionResolutionOptions.cs | 3 +- ...tions.cs => UAuthUserIdentifierOptions.cs} | 4 +- .../UAuthServerLoginOptionsValidator.cs | 22 + .../UAuthServerMultiTenantOptionsValidator.cs | 44 ++ .../Validators/UAuthServerOptionsValidator.cs | 72 +++ .../UAuthServerPkceOptionsValidator.cs | 20 + .../UAuthServerSessionOptionsValidator.cs | 29 ++ ...ServerSessionResolutionOptionsValidator.cs | 61 +++ .../UAuthServerTokenOptionsValidator.cs | 49 ++ ...uthServerUserIdentifierOptionsValidator.cs | 17 + .../Runtime/ServerRuntimeMarker.cs | 7 + .../UAuthServerProductInfo.cs | 0 .../Services/UAuthFlowService.cs | 29 +- .../Stores/EfCoreSessionStoreKernel.cs | 43 +- .../InMemorySessionStoreKernel.cs | 28 +- .../AssemblyVisibility.cs | 3 + .../Extensions/ServiceCollectionExtensions.cs | 3 + .../InMemoryUserSecurityState.cs | 11 + .../InMemoryUserSecurityStateProvider.cs | 12 +- .../InMemoryUserSecurityStateWriter.cs | 51 +++ .../Stores/InMemoryUserSecurityStore.cs | 21 + .../AssemblyVisibility.cs | 3 + .../Extensions/ServiceCollectonExtensions.cs | 2 +- .../Services/UserApplicationService.cs | 75 ++++ ...meStore.cs => UserRuntimeStateProvider.cs} | 4 +- .../Abstractions/IUserSecurityState.cs | 5 +- .../IUserSecurityStateDebugView.cs | 9 + .../Abstractions/IUserSecurityStateWriter.cs | 10 + .../AssemblyVisibility.cs | 4 + .../Client/ClientOptionsValidatorTests.cs | 65 +++ .../Core/ConfigurationGuardsTests.cs | 166 +++++++ .../Core/OptionValidatorTests.cs | 70 +++ .../Core/RefreshTokenValidatorTests.cs | 1 + .../Fake/FakeFlowClient.cs | 5 + .../Helpers/AuthFlowTestFactory.cs | 37 ++ .../Helpers/TestAccessContext.cs | 22 + .../Helpers/TestAuthModeResolver.cs | 12 + .../Helpers/TestAuthRuntime.cs | 68 +++ .../Helpers/TestClientBaseAddressResolver.cs | 19 + .../Helpers/TestDevice.cs | 8 + .../{ => Helpers}/TestHelpers.cs | 8 +- .../Helpers/TestHttpContext.cs | 23 + .../Helpers/TestHttpContextExtensions.cs | 38 ++ .../{ => Helpers}/TestIds.cs | 2 +- .../Helpers/TestPasswordHasher.cs | 9 + .../Helpers/TestRedirectResolver.cs | 38 ++ .../Helpers/TestServerOptions.cs | 24 + .../Policies/ActionTextTests.cs | 15 +- .../Server/ClientBaseAddressProviderTests.cs | 64 +++ .../Server/EffectiveAuthModeResolverTests.cs | 18 +- .../EffectiveServerOptionsProviderTests.cs | 90 ++-- .../Server/LoginOrchestratorTests.cs | 420 ++++++++++++++++++ .../Server/RedirectTests.cs | 199 +++++++++ .../Server/ReturnUrlParserTests.cs | 45 ++ .../Server/ServerOptionsValidatorTests.cs | 362 +++++++++++++++ 211 files changed, 4466 insertions(+), 1568 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs rename src/CodeBeam.UltimateAuth.Client/Options/{PkceLoginOptions.cs => UAuthClientPkceLoginFlowOptions.cs} (92%) create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceRequiredInvariant.cs rename src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/{DevicePresenceInvariant.cs => TenantResolvedInvariant.cs} (52%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/CoreConfigurationIntentDetector.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsPostConfigureGuard.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthMultiTenantOptionsValidator.cs rename src/CodeBeam.UltimateAuth.Core/Options/{ => Validators}/UAuthOptionsValidator.cs (85%) rename src/CodeBeam.UltimateAuth.Core/Options/{ => Validators}/UAuthPkceOptionsValidator.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthSessionOptionsValidator.cs rename src/CodeBeam.UltimateAuth.Core/Options/{ => Validators}/UAuthTokenOptionsValidator.cs (50%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Runtime/DirectCoreConfigurationMarker.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthRuntimeMarker.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs rename src/CodeBeam.UltimateAuth.Server/{ => Infrastructure}/Cookies/IUAuthCookieManager.cs (84%) rename src/CodeBeam.UltimateAuth.Server/{ => Infrastructure}/Cookies/IUAuthCookiePolicyBuilder.cs (85%) rename src/CodeBeam.UltimateAuth.Server/{ => Infrastructure}/Cookies/UAuthCookieManager.cs (90%) rename src/CodeBeam.UltimateAuth.Server/{ => Infrastructure}/Cookies/UAuthCookiePolicyBuilder.cs (93%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ClientBaseAdressResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ConfiguredClientBaseAddressProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IClientBaseAddressProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IFallbackClientBaseAddressProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/OriginHeaderBaseAddressProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RefererHeaderBaseAddressProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RequestHostBaseAddressProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlInfo.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/UrlComposer.cs rename src/CodeBeam.UltimateAuth.Server/Options/{UAuthCookieSetOptions.cs => UAuthCookiePolicyOptions.cs} (52%) rename src/CodeBeam.UltimateAuth.Server/Options/{PrimaryCredentialPolicy.cs => UAuthPrimaryCredentialPolicy.cs} (82%) rename src/CodeBeam.UltimateAuth.Server/Options/{AuthResponseOptions.cs => UAuthResponseOptions.cs} (87%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs rename src/CodeBeam.UltimateAuth.Server/Options/{UserIdentifierOptions.cs => UAuthUserIdentifierOptions.cs} (90%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerMultiTenantOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPkceOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionResolutionOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Runtime/ServerRuntimeMarker.cs rename src/CodeBeam.UltimateAuth.Server/{ProductInfo => Runtime}/UAuthServerProductInfo.cs (100%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/AssemblyVisibility.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/AssemblyVisibility.cs rename src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/{UserRuntimeStore.cs => UserRuntimeStateProvider.cs} (85%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/AssemblyVisibility.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ConfigurationGuardsTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClientBaseAddressResolver.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs rename tests/CodeBeam.UltimateAuth.Tests.Unit/{ => Helpers}/TestHelpers.cs (59%) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs rename tests/CodeBeam.UltimateAuth.Tests.Unit/{ => Helpers}/TestIds.cs (85%) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestServerOptions.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ClientBaseAddressProviderTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index 29c14045..f978c35e 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -9,7 +9,6 @@ @using CodeBeam.UltimateAuth.Core.Domain @using CodeBeam.UltimateAuth.Core.Runtime @using CodeBeam.UltimateAuth.Server.Abstractions -@using CodeBeam.UltimateAuth.Server.Cookies @using CodeBeam.UltimateAuth.Server.Infrastructure @using CodeBeam.UltimateAuth.Server.Services @using CodeBeam.UltimateAuth.Server.Stores @@ -53,7 +52,6 @@ @ProductInfo.Get().ProductName v @ProductInfo.Get().Version - Client Profile: @ProductInfo.Get().ClientProfile.ToString() 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 index 0ebd5cf8..d69732e0 100644 --- 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 @@ -1,14 +1,14 @@  - net10.0 + net9.0 enable enable 0.0.1-preview - + 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 index 259133b0..fc6ba3df 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -4,10 +4,10 @@ @using CodeBeam.UltimateAuth.Client.Authentication @using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Runtime @using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Runtime @using CodeBeam.UltimateAuth.Server.Abstractions -@using CodeBeam.UltimateAuth.Server.Cookies @using CodeBeam.UltimateAuth.Server.Infrastructure @using CodeBeam.UltimateAuth.Server.Services @inject IUAuthStateManager StateManager @@ -20,7 +20,7 @@ @inject IHttpContextAccessor HttpContextAccessor @inject IUAuthClient UAuth @inject NavigationManager Nav -@inject IUAuthProductInfoProvider ProductInfo +@inject IUAuthClientProductInfoProvider ClientProductInfo @inject AuthenticationStateProvider AuthStateProvider @inject UAuthClientDiagnostics Diagnostics @inject IDeviceIdProvider DeviceIdProvider @@ -50,8 +50,8 @@ - @ProductInfo.Get().ProductName v @ProductInfo.Get().Version - Client Profile: @ProductInfo.Get().ClientProfile.ToString() + @ClientProductInfo.Get().ProductName v @ClientProductInfo.Get().Version + Client Profile: @ClientProductInfo.Get().ClientProfile.ToString() 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 index 6b5ce18b..3e27d40a 100644 --- 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 @@ -1,146 +1,144 @@ using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; -namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class Home { - public partial class Home - { - private string? _username; - private string? _password; + private string? _username; + private string? _password; - private UALoginForm _form = null!; + private UALoginForm _form = null!; - private AuthenticationState _authState = null!; + private AuthenticationState _authState = null!; - protected override async Task OnInitializedAsync() - { - Diagnostics.Changed += OnDiagnosticsChanged; - } + protected override async Task OnInitializedAsync() + { + Diagnostics.Changed += OnDiagnosticsChanged; + } - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) { - if (firstRender) - { - await StateManager.EnsureAsync(); - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); - StateHasChanged(); - } + await StateManager.EnsureAsync(); + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + StateHasChanged(); } + } - private void OnDiagnosticsChanged() - { - InvokeAsync(StateHasChanged); - } + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } - private async Task ProgrammaticLogin() + private async Task ProgrammaticLogin() + { + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); + var request = new LoginRequest { - var deviceId = await DeviceIdProvider.GetOrCreateAsync(); - var request = new LoginRequest - { - Identifier = "admin", - Secret = "admin", - Device = DeviceContext.FromDeviceId(deviceId), - }; - await UAuth.Flows.LoginAsync(request); - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); - } + Identifier = "admin", + Secret = "admin", + Device = DeviceContext.FromDeviceId(deviceId), + }; + await UAuth.Flows.LoginAsync(request); + _authState = await AuthStateProvider.GetAuthenticationStateAsync(); + } - private async Task ValidateAsync() - { - var result = await UAuth.Flows.ValidateAsync(); + private async Task ValidateAsync() + { + var result = await UAuth.Flows.ValidateAsync(); - Snackbar.Add( - result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", - result.IsValid ? Severity.Success : Severity.Error); - } + Snackbar.Add( + result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", + result.IsValid ? Severity.Success : Severity.Error); + } - private async Task LogoutAsync() + private async Task LogoutAsync() + { + await UAuth.Flows.LogoutAsync(); + Snackbar.Add("Logged out", Severity.Success); + } + + private async Task RefreshAsync() + { + await UAuth.Flows.RefreshAsync(); + } + + private async Task HandleGetMe() + { + var profileResult = await UAuth.Users.GetMeAsync(); + if (profileResult.Ok) { - await UAuth.Flows.LogoutAsync(); - Snackbar.Add("Logged out", Severity.Success); + var profile = profileResult.Value; + Snackbar.Add($"User Profile: {profile?.UserName} ({profile?.DisplayName})", Severity.Info); } - - private async Task RefreshAsync() + else { - await UAuth.Flows.RefreshAsync(); + Snackbar.Add($"Failed to get profile: {profileResult.Error}", Severity.Error); } + } - private async Task HandleGetMe() + private async Task ChangeUserInactive() + { + ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest { - var profileResult = await UAuth.Users.GetMeAsync(); - if (profileResult.Ok) - { - var profile = profileResult.Value; - Snackbar.Add($"User Profile: {profile?.UserName} ({profile?.DisplayName})", Severity.Info); - } - else - { - Snackbar.Add($"Failed to get profile: {profileResult.Error}", Severity.Error); - } + UserKey = UserKey.FromString("user"), + NewStatus = UserStatus.Disabled + }; + var result = await UAuth.Users.ChangeStatusAdminAsync(request); + if (result.Ok) + { + Snackbar.Add($"User is disabled.", Severity.Info); } - - private async Task ChangeUserInactive() + else { - ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest - { - UserKey = UserKey.FromString("user"), - NewStatus = UserStatus.Disabled - }; - var result = await UAuth.Users.ChangeStatusAdminAsync(request); - if (result.Ok) - { - Snackbar.Add($"User is disabled.", Severity.Info); - } - else - { - Snackbar.Add($"Failed to change user status.", Severity.Error); - } + Snackbar.Add($"Failed to change user status.", Severity.Error); } + } - protected override void OnAfterRender(bool firstRender) + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) { - if (firstRender) + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("error", out var error)) { - var uri = Nav.ToAbsoluteUri(Nav.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - - if (query.TryGetValue("error", out var error)) - { - ShowLoginError(error.ToString()); - ClearQueryString(); - } + ShowLoginError(error.ToString()); + ClearQueryString(); } } + } - private void ShowLoginError(string code) + private void ShowLoginError(string code) + { + var message = code switch { - var message = code switch - { - "invalid" => "Invalid username or password.", - "locked" => "Your account is locked.", - "mfa" => "Multi-factor authentication required.", - _ => "Login failed." - }; + "invalid" => "Invalid username or password.", + "locked" => "Your account is locked.", + "mfa" => "Multi-factor authentication required.", + _ => "Login failed." + }; - Snackbar.Add(message, Severity.Error); - } - - private void ClearQueryString() - { - var uri = new Uri(Nav.Uri); - var clean = uri.GetLeftPart(UriPartial.Path); - Nav.NavigateTo(clean, replace: true); - } + Snackbar.Add(message, Severity.Error); + } - public void Dispose() - { - Diagnostics.Changed -= OnDiagnosticsChanged; - } + private void ClearQueryString() + { + var uri = new Uri(Nav.Uri); + var clean = uri.GetLeftPart(UriPartial.Path); + Nav.NavigateTo(clean, replace: true); + } + public void Dispose() + { + Diagnostics.Changed -= OnDiagnosticsChanged; } + } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 6791f6d6..0d72839b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -15,6 +15,7 @@ using CodeBeam.UltimateAuth.Server.Authentication; using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; using CodeBeam.UltimateAuth.Users.InMemory; @@ -54,11 +55,13 @@ builder.Services.AddUltimateAuth(); -builder.Services.AddUltimateAuthServer(o => { +builder.Services.AddUltimateAuthServer(o => +{ o.Diagnostics.EnableRefreshHeaders = true; //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); + o.AuthResponse.Login.AllowReturnUrlOverride = true; }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() 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 index da54578a..f9cd744d 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -12,7 +12,7 @@ @inject ISnackbar Snackbar @inject IUAuthClient UAuthClient @inject NavigationManager Nav -@inject IUAuthProductInfoProvider ProductInfo +@inject IUAuthClientProductInfoProvider ClientProductInfo @inject AuthenticationStateProvider AuthStateProvider @inject UAuthClientDiagnostics Diagnostics @inject IUAuthClientBootstrapper Bootstrapper @@ -41,8 +41,8 @@ - @ProductInfo.Get().ProductName v @ProductInfo.Get().Version - Client Profile: @ProductInfo.Get().ClientProfile.ToString() + @ClientProductInfo.Get().ProductName v @ClientProductInfo.Get().Version + Client Profile: @ClientProductInfo.Get().ClientProfile.ToString() diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 98ba1830..1a438a17 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -15,7 +15,7 @@ builder.Services.AddUltimateAuth(); builder.Services.AddUltimateAuthClient(o => { - o.Endpoints.Authority = "https://localhost:6110"; + o.Endpoints.BasePath = "https://localhost:6110"; }); //builder.Services.AddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor index c7678bea..2d3cafbc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor @@ -7,7 +7,6 @@ @using CodeBeam.UltimateAuth.Core.Options @using Microsoft.Extensions.Options @inject IJSRuntime JS -@inject IOptions CoreOptions @inject IOptions Options @inject NavigationManager Navigation diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs index b747c717..8337cd2e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs @@ -115,7 +115,7 @@ public async Task SubmitAsync() await JS.InvokeVoidAsync("uauth.submitForm", _form); } - private string ClientProfileValue => CoreOptions.Value.ClientProfile.ToString(); + private string ClientProfileValue => Options.Value.ClientProfile.ToString(); private string EffectiveEndpoint => LoginType == UAuthLoginType.Pkce ? Options.Value.Endpoints.PkceComplete @@ -130,7 +130,7 @@ private string ResolvedEndpoint ? EffectiveEndpoint : Endpoint; - var baseUrl = UAuthUrlBuilder.Combine(Options.Value.Endpoints.Authority, loginPath); + var baseUrl = UAuthUrlBuilder.Build(Options.Value.Endpoints.BasePath, loginPath, Options.Value.MultiTenant); var returnUrl = EffectiveReturnUrl; if (string.IsNullOrWhiteSpace(returnUrl)) diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs new file mode 100644 index 00000000..6a13be2f --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 0ae74a5e..451e8fc9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -12,7 +12,6 @@ using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -32,34 +31,19 @@ namespace CodeBeam.UltimateAuth.Client.Extensions; /// public static class ServiceCollectionExtensions { - /// - /// Registers UltimateAuth client services using configuration binding - /// (e.g. appsettings.json). - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, IConfiguration configurationSection) + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, Action? configure = null) { - services.Configure(configurationSection); - return services.AddUltimateAuthClientInternal(); - } + ArgumentNullException.ThrowIfNull(services); - /// - /// Registers UltimateAuth client services using programmatic configuration. - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, Action configure) - { - services.Configure(configure); - return services.AddUltimateAuthClientInternal(); - } + services.AddOptions() + // Program.cs configuration (lowest precedence) + .Configure(options => + { + configure?.Invoke(options); + }) + // appsettings.json (highest precedence) + .BindConfiguration("UltimateAuth:Client"); - /// - /// Registers UltimateAuth client services with default (empty) configuration. - /// - /// Intended for advanced scenarios where configuration is fully controlled - /// by the hosting application or overridden later. - /// - public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services) - { - services.Configure(_ => { }); return services.AddUltimateAuthClientInternal(); } @@ -75,26 +59,19 @@ public static IServiceCollection AddUltimateAuthClient(this IServiceCollection s /// private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCollection services) { - // Options validation can be added here later if needed - // services.AddSingleton, ...>(); + services.AddScoped(); + + services.AddOptions(); + services.AddSingleton, UAuthClientOptionsValidator>(); + services.AddSingleton, UAuthClientEndpointOptionsValidator>(); services.AddSingleton(); - services.AddSingleton, UAuthOptionsPostConfigure>(); + services.AddSingleton, UAuthClientOptionsPostConfigure>(); services.TryAddSingleton(); - //services.PostConfigure(o => - //{ - // if (!o.AutoDetectClientProfile || o.ClientProfile != UAuthClientProfile.NotSpecified) - // return; - - // using var sp = services.BuildServiceProvider(); - // var detector = sp.GetRequiredService(); - // o.ClientProfile = detector.Detect(sp); - //}); - services.PostConfigure(o => { - o.Refresh.Interval ??= TimeSpan.FromMinutes(5); + o.AutoRefresh.Interval ??= TimeSpan.FromMinutes(5); }); services.TryAddScoped(); @@ -107,9 +84,9 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddScoped(sp => { - var core = sp.GetRequiredService>().Value; + var options = sp.GetRequiredService>().Value; - return core.ClientProfile == UAuthClientProfile.BlazorServer + return options.ClientProfile == UAuthClientProfile.BlazorServer ? sp.GetRequiredService() : sp.GetRequiredService(); }); diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs index 0857f31b..d74959bf 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs @@ -30,12 +30,15 @@ public BlazorServerSessionCoordinator(IUAuthClient client, NavigationManager nav public async Task StartAsync(CancellationToken cancellationToken = default) { + if (!_options.AutoRefresh.Enabled) + return; + if (_timer is not null) return; _diagnostics.MarkStarted(); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var interval = _options.Refresh.Interval ?? TimeSpan.FromMinutes(5); + var interval = _options.AutoRefresh.Interval ?? TimeSpan.FromMinutes(5); _timer = new PeriodicTimer(interval); _ = RunAsync(_cts.Token); @@ -64,8 +67,8 @@ private async Task RunAsync(CancellationToken ct) case RefreshOutcome.ReauthRequired: switch (_options.Reauth.Behavior) { - case ReauthBehavior.RedirectToLogin: - _navigation.NavigateTo(_options.Reauth.LoginPath, forceLoad: true); + case ReauthBehavior.Redirect: + _navigation.NavigateTo(_options.Reauth.RedirectPath ?? _options.Endpoints.Login, forceLoad: true); break; case ReauthBehavior.RaiseEvent: diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs new file mode 100644 index 00000000..6b8138c8 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs index bedb3795..0f3ce69c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.Extensions.Options; using Microsoft.JSInterop; @@ -10,12 +11,12 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; internal sealed class UAuthRequestClient : IUAuthRequestClient { private readonly IJSRuntime _js; - private UAuthOptions _coreOptions; + private UAuthClientOptions _options; - public UAuthRequestClient(IJSRuntime js, IOptions coreOptions) + public UAuthRequestClient(IJSRuntime js, IOptions options) { _js = js; - _coreOptions = coreOptions.Value; + _options = options.Value; } public Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) @@ -27,7 +28,7 @@ public Task NavigateAsync(string endpoint, IDictionary? form = n url = endpoint, mode = "navigate", data = form, - clientProfile = _coreOptions.ClientProfile.ToString() + clientProfile = _options.ClientProfile.ToString() }).AsTask(); } @@ -41,7 +42,7 @@ public async Task SendFormAsync(string endpoint, IDictiona mode = "fetch", expectJson = false, data = form, - clientProfile = _coreOptions.ClientProfile.ToString() + clientProfile = _options.ClientProfile.ToString() }); return result; @@ -59,7 +60,7 @@ public async Task SendFormForJsonAsync(string endpoint, ID mode = "fetch", expectJson = true, data = postData, - clientProfile = _coreOptions.ClientProfile.ToString() + clientProfile = _options.ClientProfile.ToString() }); } @@ -71,7 +72,7 @@ public async Task SendJsonAsync(string endpoint, object? p { url = endpoint, payload = payload, - clientProfile = _coreOptions.ClientProfile.ToString() + clientProfile = _options.ClientProfile.ToString() }); } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs index 2d4cbb74..7137175b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -1,9 +1,29 @@ -namespace CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; internal static class UAuthUrlBuilder { - public static string Combine(string authority, string relative) + //public static string Combine(string authority, string relative) + //{ + // return authority.TrimEnd('/') + "/" + relative.TrimStart('/'); + //} + + public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant) { - return authority.TrimEnd('/') + "/" + relative.TrimStart('/'); + 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/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs new file mode 100644 index 00000000..eebd7e3f --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs new file mode 100644 index 00000000..f1cd1281 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs @@ -0,0 +1,18 @@ +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 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 PkceComplete { get; set; } = "/pkce/complete"; + public string HubLoginPath { get; set; } = "/uauthhub/login"; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs new file mode 100644 index 00000000..9cb1d376 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs new file mode 100644 index 00000000..c5d9caf6 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index 0aabb867..dab4589d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -1,74 +1,26 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Client.Options; public sealed class UAuthClientOptions { - public AuthEndpointOptions Endpoints { get; set; } = new(); - public LoginOptions Login { get; set; } = new(); - public UAuthClientRefreshOptions Refresh { get; set; } = new(); - public ReauthOptions Reauth { get; init; } = new(); -} + public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; + public bool AutoDetectClientProfile { get; set; } = true; -public sealed class AuthEndpointOptions -{ /// - /// Base URL of UAuthHub (e.g. https://localhost:6110) - /// - public string Authority { get; set; } = "/auth"; - - public string Login { get; set; } = "/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 PkceComplete { get; set; } = "/pkce/complete"; - public string HubLoginPath { get; set; } = "/uauthhub/login"; -} - -public sealed class LoginOptions -{ - /// - /// Default return URL after a successful login flow. - /// If not set, current location will be used. + /// Global fallback return URL used by interactive authentication flows + /// when no flow-specific return URL is provided. /// public string? DefaultReturnUrl { get; set; } - /// - /// Options related to PKCE-based login flows. - /// - public PkceLoginOptions Pkce { get; set; } = new(); - - /// - /// Enables or disables direct credential-based login. - /// - public bool AllowDirectLogin { get; set; } = true; -} - -public sealed class UAuthClientRefreshOptions -{ - /// - /// Enables background refresh coordination. - /// Default: true for BlazorServer, false otherwise. - /// - public bool Enabled { get; set; } = true; + public UAuthClientEndpointOptions Endpoints { get; set; } = new(); + public UAuthClientLoginFlowOptions Login { get; set; } = new(); /// - /// Interval for background refresh attempts. - /// This is a UX / keep-alive setting, NOT a security policy. - /// - public TimeSpan? Interval { get; set; } - - /// - /// Optional jitter to avoid synchronized refresh storms. + /// Options related to PKCE-based login flows. /// - public TimeSpan? Jitter { get; set; } -} - -// TODO: Add ClearCookieOnReauth -public sealed class ReauthOptions -{ - public ReauthBehavior Behavior { get; set; } = ReauthBehavior.RedirectToLogin; - public string LoginPath { get; set; } = "/login"; + 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/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs rename to src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs index 2f82cd8f..6052ba7c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/PkceLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Client.Options; -public sealed class PkceLoginOptions +public sealed class UAuthClientPkceLoginFlowOptions { /// /// Enables PKCE login support. diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs new file mode 100644 index 00000000..3cb6796f --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs index 4c2aa91c..d725091e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs @@ -1,20 +1,21 @@ -using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Options; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Client.Infrastructure; -internal sealed class UAuthOptionsPostConfigure : IPostConfigureOptions +internal sealed class UAuthClientOptionsPostConfigure : IPostConfigureOptions { private readonly IClientProfileDetector _detector; private readonly IServiceProvider _services; - public UAuthOptionsPostConfigure(IClientProfileDetector detector, IServiceProvider services) + public UAuthClientOptionsPostConfigure(IClientProfileDetector detector, IServiceProvider services) { _detector = detector; _services = services; } - public void PostConfigure(string? name, UAuthOptions options) + public void PostConfigure(string? name, UAuthClientOptions options) { if (!options.AutoDetectClientProfile) return; diff --git a/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs new file mode 100644 index 00000000..d00b4f2e --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs new file mode 100644 index 00000000..98e2ec3c --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs b/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs deleted file mode 100644 index 50f66883..00000000 --- a/src/CodeBeam.UltimateAuth.Client/ProductInfo/UAuthClientProductInfo.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Runtime; - -namespace CodeBeam.UltimateAuth.Client.Runtime; - -public sealed class UAuthClientProductInfo -{ - public string ProductName { get; init; } = "UltimateAuthClient"; - public UAuthProductInfo Core { get; init; } = default!; -} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs new file mode 100644 index 00000000..d240224a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Client.Runtime; + +public interface IUAuthClientProductInfoProvider +{ + UAuthClientProductInfo Get(); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs new file mode 100644 index 00000000..fb7e4874 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs @@ -0,0 +1,15 @@ +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"); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs new file mode 100644 index 00000000..696163b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs @@ -0,0 +1,25 @@ +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; + + _info = new UAuthClientProductInfo + { + Version = asm.GetName().Version?.ToString(3) ?? "unknown", + InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, + StartedAt = DateTimeOffset.UtcNow, + ClientProfile = options.Value.ClientProfile + }; + } + + public UAuthClientProductInfo Get() => _info; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs index c99d1456..3d85ecf6 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IFlowClient { - Task LoginAsync(LoginRequest request); + Task LoginAsync(LoginRequest request, string? returnUrl = null); Task LogoutAsync(); Task RefreshAsync(bool isAuto = false); Task ReauthAsync(); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index d4ed5fa7..fd79168e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -18,31 +18,29 @@ public UAuthAuthorizationClient(IUAuthRequestClient request, IOptions UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + public async Task> CheckAsync(AuthorizationCheckRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/check"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/authorization/check"), request); return UAuthResultMapper.FromJson(raw); } public async Task> GetMyRolesAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/users/me/roles/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url("/authorization/users/me/roles/get")); return UAuthResultMapper.FromJson(raw); } public async Task> GetUserRolesAsync(UserKey userKey) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/get")); return UAuthResultMapper.FromJson(raw); } public async Task AssignRoleAsync(UserKey userKey, string role) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/post"); - var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/post"), new AssignRoleRequest { Role = role }); @@ -52,9 +50,7 @@ public async Task AssignRoleAsync(UserKey userKey, string role) public async Task RemoveRoleAsync(UserKey userKey, string role) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/delete"); - - var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/delete"), new AssignRoleRequest { Role = role }); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 55b1fcf4..1d1d1be2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -18,7 +18,7 @@ public UAuthCredentialClient(IUAuthRequestClient request, IOptions UAuthUrlBuilder.Combine(_options.Endpoints.Authority, path); + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); public async Task> GetMyAsync() { diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 128cd83d..2cea3e7c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -6,7 +6,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using System.Security.Cryptography; @@ -19,33 +18,50 @@ internal class UAuthFlowClient : IFlowClient { private readonly IUAuthRequestClient _post; private readonly UAuthClientOptions _options; - private readonly UAuthOptions _coreOptions; private readonly UAuthClientDiagnostics _diagnostics; private readonly NavigationManager _nav; - public UAuthFlowClient( - IUAuthRequestClient post, - IOptions options, - IOptions coreOptions, - UAuthClientDiagnostics diagnostics, - NavigationManager nav) + public UAuthFlowClient(IUAuthRequestClient post, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) { _post = post; _options = options.Value; - _coreOptions = coreOptions.Value; _diagnostics = diagnostics; _nav = nav; } - public async Task LoginAsync(LoginRequest request) + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + public async Task LoginAsync(LoginRequest request, string? returnUrl = null) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Login); - await _post.NavigateAsync(url, request.ToDictionary()); + 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."); + } + + var resolvedReturnUrl = + returnUrl + ?? _options.Login.ReturnUrl + ?? _options.DefaultReturnUrl; + + var payload = request.ToDictionary(); + + if (!string.IsNullOrWhiteSpace(resolvedReturnUrl)) + { + payload["return_url"] = resolvedReturnUrl; + } + + var url = Url(_options.Endpoints.Login); + await _post.NavigateAsync(url, payload); } public async Task LogoutAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Logout); + var url = Url(_options.Endpoints.Logout); await _post.NavigateAsync(url); } @@ -56,7 +72,7 @@ public async Task RefreshAsync(bool isAuto = false) _diagnostics.MarkManualRefresh(); } - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Refresh); + var url = Url(_options.Endpoints.Refresh); var result = await _post.SendFormAsync(url); var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); switch (refreshOutcome) @@ -85,13 +101,13 @@ public async Task RefreshAsync(bool isAuto = false) public async Task ReauthAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Reauth); + var url = Url(_options.Endpoints.Reauth); await _post.NavigateAsync(_options.Endpoints.Reauth); } public async Task ValidateAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.Validate); + var url = Url(_options.Endpoints.Validate); var raw = await _post.SendFormForJsonAsync(url); if (!raw.Ok || raw.Body is null) @@ -118,7 +134,7 @@ public async Task ValidateAsync() public async Task BeginPkceAsync(string? returnUrl = null) { - var pkce = _options.Login.Pkce; + var pkce = _options.Pkce; if (!pkce.Enabled) throw new InvalidOperationException("PKCE login is disabled by configuration."); @@ -126,7 +142,7 @@ public async Task BeginPkceAsync(string? returnUrl = null) var verifier = CreateVerifier(); var challenge = CreateChallenge(verifier); - var authorizeUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceAuthorize); + var authorizeUrl = Url(_options.Endpoints.PkceAuthorize); var raw = await _post.SendFormForJsonAsync( authorizeUrl, @@ -150,7 +166,8 @@ public async Task BeginPkceAsync(string? returnUrl = null) var resolvedReturnUrl = returnUrl ?? pkce.ReturnUrl - ?? _options.Login.DefaultReturnUrl + ?? _options.Login.ReturnUrl + ?? _options.DefaultReturnUrl ?? _nav.Uri; if (pkce.AutoRedirect) @@ -164,7 +181,13 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) if (request is null) throw new ArgumentNullException(nameof(request)); - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.PkceComplete); + 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 { @@ -181,14 +204,14 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) { - var hubLoginUrl = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, _options.Endpoints.HubLoginPath); + var hubLoginUrl = Url(_options.Endpoints.HubLoginPath); var data = new Dictionary { ["authorization_code"] = authorizationCode, ["code_verifier"] = codeVerifier, ["return_url"] = returnUrl, - ["client_profile"] = _coreOptions.ClientProfile.ToString() + ["client_profile"] = _options.ClientProfile.ToString() }; return _post.NavigateAsync(hubLoginUrl, data); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index e13a3f8b..250b4733 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -18,59 +18,53 @@ public UAuthUserClient(IUAuthRequestClient request, IOptions _options = options.Value; } + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + public async Task> GetMeAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url("/users/me/get")); return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/update"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/update"), request); return UAuthResultMapper.FromStatus(raw); } public async Task> CreateAsync(CreateUserRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/create"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/create"), request); return UAuthResultMapper.FromJson(raw); } public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/status"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/status"), request); return UAuthResultMapper.FromJson(raw); } public async Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{request.UserKey.Value}/status"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{request.UserKey.Value}/status"), request); return UAuthResultMapper.FromJson(raw); } public async Task> DeleteAsync(DeleteUserRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/delete"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/delete")); return UAuthResultMapper.FromJson(raw); } public async Task> GetProfileAsync(UserKey userKey) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey}/profile/get")); return UAuthResultMapper.FromJson(raw); } public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/update"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/profile/update"), request); return UAuthResultMapper.FromStatus(raw); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index 6aeeedca..96c3460b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -18,102 +18,89 @@ public UAuthUserIdentifierClient(IUAuthRequestClient request, IOptions UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + public async Task>> GetMyIdentifiersAsync() { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/identifiers/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url("/users/me/identifiers/get")); return UAuthResultMapper.FromJson>(raw); } public async Task AddSelfAsync(AddUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/add"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/add"), request); return UAuthResultMapper.FromStatus(raw); } public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/update"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/update"), request); return UAuthResultMapper.FromStatus(raw); } public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/set-primary"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/set-primary"), request); return UAuthResultMapper.FromStatus(raw); } public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/unset-primary"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/unset-primary"), request); return UAuthResultMapper.FromStatus(raw); } public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/verify"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/verify"), request); return UAuthResultMapper.FromStatus(raw); } public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/delete"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/delete"), request); return UAuthResultMapper.FromStatus(raw); } public async Task>> GetUserIdentifiersAsync(UserKey userKey) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey.Value}/identifiers/get"); - var raw = await _request.SendFormForJsonAsync(url); + var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/get")); return UAuthResultMapper.FromJson>(raw); } public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/add"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/add"), request); return UAuthResultMapper.FromStatus(raw); } public async Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/update"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/update"), request); return UAuthResultMapper.FromStatus(raw); } public async Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/set-primary"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/set-primary"), request); return UAuthResultMapper.FromStatus(raw); } public async Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/unset-primary"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/unset-primary"), request); return UAuthResultMapper.FromStatus(raw); } public async Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/verify"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/verify"), request); return UAuthResultMapper.FromStatus(raw); } public async Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/delete"); - var raw = await _request.SendJsonAsync(url, request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/delete"), request); return UAuthResultMapper.FromStatus(raw); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs index 2fe227d1..e8e11ca7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs @@ -2,6 +2,7 @@ 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/Issuers/IOpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs index a5332d1f..63e53b5c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs @@ -6,5 +6,6 @@ /// public interface IOpaqueTokenGenerator { - string Generate(int byteLength = 32); + string Generate(); + string GenerateJwtId(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index 3e8bca20..f78e4806 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -10,7 +10,7 @@ public interface ISessionIssuer Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); - Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, 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); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs index 05148ed1..09e130c8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs @@ -5,10 +5,11 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface ISessionStoreKernel { Task ExecuteAsync(Func action, CancellationToken ct = default); + Task ExecuteAsync(Func> action, CancellationToken ct = default); Task GetSessionAsync(AuthSessionId sessionId); Task SaveSessionAsync(UAuthSession session); - Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); + Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); Task GetChainAsync(SessionChainId chainId); Task SaveChainAsync(UAuthSessionChain chain); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index ca4b8406..320faef7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -14,23 +14,47 @@ public sealed class AccessContext // Target public string? Resource { get; init; } - public string? ResourceId { 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 && ResourceId != null && string.Equals(ActorUserKey.Value, ResourceId, 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 => ResourceId != null; + public bool HasTarget => TargetUserKey != null; public UserKey GetTargetUserKey() { - if (ResourceId is null) - throw new InvalidOperationException("Target user is not specified."); + if (TargetUserKey is not UserKey targetUserKey) + throw new InvalidOperationException("Target user is not found."); - return UserKey.Parse(ResourceId, null); + return targetUserKey; + } + + internal AccessContext( + UserKey? actorUserKey, + TenantKey actorTenant, + bool isAuthenticated, + bool isSystemActor, + string resource, + UserKey? targetUserKey, + TenantKey resourceTenant, + string action, + IReadOnlyDictionary attributes) + { + ActorUserKey = actorUserKey; + ActorTenant = actorTenant; + IsAuthenticated = isAuthenticated; + IsSystemActor = isSystemActor; + + Resource = resource; + TargetUserKey = targetUserKey; + ResourceTenant = resourceTenant; + + Action = action; + Attributes = attributes; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs index 65eeadaf..0424ad16 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -1,10 +1,13 @@ 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; } 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..c53276d3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum LogoutReason +{ + Explicit, + SessionExpired, + SecurityPolicy, + AdminForced, + TenantDisabled +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs index 4a83eb93..070c1551 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs @@ -15,6 +15,7 @@ public sealed class AuthenticatedSessionContext 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. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs index 223176fe..3be8b529 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -12,4 +12,5 @@ public sealed record SessionRotationContext 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/Domain/Hub/HubFlowArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs index 3d6c99a0..f85e5c9a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs @@ -22,7 +22,7 @@ public HubFlowArtifact( string? returnUrl, HubFlowPayload payload, DateTimeOffset expiresAt) - : base(AuthArtifactType.HubFlow, expiresAt, maxAttempts: 1) + : base(AuthArtifactType.HubFlow, expiresAt) { HubSessionId = hubSessionId; FlowType = flowType; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs index a0c69a46..1210c167 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/AuthArtifact.cs @@ -2,26 +2,21 @@ public abstract class AuthArtifact { - protected AuthArtifact(AuthArtifactType type, DateTimeOffset expiresAt, int maxAttempts) + protected AuthArtifact(AuthArtifactType type, DateTimeOffset expiresAt) { Type = type; ExpiresAt = expiresAt; - MaxAttempts = maxAttempts; } public AuthArtifactType Type { get; } public DateTimeOffset ExpiresAt { get; internal set; } - public int MaxAttempts { get; } - public int AttemptCount { get; private set; } public bool IsCompleted { get; private set; } public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt; - public bool CanAttempt() => AttemptCount < MaxAttempts; - public void RegisterAttempt() { AttemptCount++; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs index 02751dcb..be3f758b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs @@ -8,9 +8,8 @@ public sealed class HubLoginArtifact : AuthArtifact public HubLoginArtifact( string authorizationCode, string codeVerifier, - DateTimeOffset expiresAt, - int maxAttempts = 3) - : base(AuthArtifactType.HubLogin, expiresAt, maxAttempts) + DateTimeOffset expiresAt) + : base(AuthArtifactType.HubLogin, expiresAt) { AuthorizationCode = authorizationCode; CodeVerifier = codeVerifier; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs index 315337c0..44262fe4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs @@ -2,7 +2,7 @@ public enum ReauthBehavior { - RedirectToLogin, + Redirect, None, RaiseEvent } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs deleted file mode 100644 index 341acbdc..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs +++ /dev/null @@ -1,47 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Events; - -/// -/// Represents contextual data emitted when a new authentication session is created. -/// -/// This event is published immediately after a successful login or initial session -/// creation within a session chain. It provides the essential identifiers required -/// for auditing, monitoring, analytics, and external integrations. -/// -/// Handlers should treat this event as notification-only; modifying session state -/// or performing security-critical actions is not recommended unless explicitly intended. -/// -public sealed class SessionCreatedContext : IAuthEventContext -{ - /// - /// Gets the identifier of the user for whom the new session was created. - /// - public TUserId UserId { get; } - - /// - /// Gets the unique identifier of the newly created session. - /// - public AuthSessionId SessionId { get; } - - /// - /// Gets the identifier of the session chain to which this session belongs. - /// - public SessionChainId ChainId { get; } - - /// - /// Gets the timestamp on which the session was created. - /// - public DateTimeOffset CreatedAt { get; } - - /// - /// Initializes a new instance of the class. - /// - public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, SessionChainId chainId, DateTimeOffset createdAt) - { - UserId = userId; - SessionId = sessionId; - ChainId = chainId; - CreatedAt = createdAt; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs deleted file mode 100644 index d3d4b0f3..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs +++ /dev/null @@ -1,59 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Events; - -/// -/// Represents contextual data emitted when an authentication session is refreshed. -/// -/// This event occurs whenever a valid session performs a rotation — typically during -/// a refresh-token exchange or session renewal flow. The old session becomes inactive, -/// and a new session inherits updated expiration and security metadata. -/// -/// This event is primarily used for analytics, auditing, security monitoring, and -/// external workflow triggers (e.g., notifying users of new logins, updating dashboards, -/// or tracking device activity). -/// -public sealed class SessionRefreshedContext : IAuthEventContext -{ - /// - /// Gets the identifier of the user whose session was refreshed. - /// - public TUserId UserId { get; } - - /// - /// Gets the identifier of the session that was replaced during the refresh operation. - /// - public AuthSessionId OldSessionId { get; } - - /// - /// Gets the identifier of the newly created session that replaces the old session. - /// - public AuthSessionId NewSessionId { get; } - - /// - /// Gets the identifier of the session chain to which both sessions belong. - /// - public SessionChainId ChainId { get; } - - /// - /// Gets the timestamp at which the refresh occurred. - /// - public DateTimeOffset RefreshedAt { get; } - - /// - /// Initializes a new instance of the class. - /// - public SessionRefreshedContext( - TUserId userId, - AuthSessionId oldSessionId, - AuthSessionId newSessionId, - SessionChainId chainId, - DateTimeOffset refreshedAt) - { - UserId = userId; - OldSessionId = oldSessionId; - NewSessionId = newSessionId; - ChainId = chainId; - RefreshedAt = refreshedAt; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs deleted file mode 100644 index 04f0040d..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs +++ /dev/null @@ -1,55 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Events; - -/// -/// Represents contextual data emitted when an individual session is revoked. -/// -/// This event is triggered when a specific session is invalidated — either due to -/// explicit logout, administrator action, security enforcement, or anomaly detection. -/// Only the targeted session is revoked; other sessions in the same chain or root -/// may continue to remain active unless broader revocation policies apply. -/// -/// Typical use cases include: -/// - Auditing and compliance logs -/// - User notifications (e.g., “Your session on device X was logged out”) -/// - Security automations (SIEM integration, monitoring suspicious activity) -/// - Application workflows that must respond to session termination -/// -public sealed class SessionRevokedContext : IAuthEventContext -{ - /// - /// Gets the identifier of the user to whom the revoked session belongs. - /// - public TUserId UserId { get; } - - /// - /// Gets the identifier of the session that has been revoked. - /// - public AuthSessionId SessionId { get; } - - /// - /// Gets the identifier of the session chain containing the revoked session. - /// - public SessionChainId ChainId { get; } - - /// - /// Gets the timestamp at which the session revocation occurred. - /// - public DateTimeOffset RevokedAt { get; } - - /// - /// Initializes a new instance of the class. - /// - public SessionRevokedContext( - TUserId userId, - AuthSessionId sessionId, - SessionChainId chainId, - DateTimeOffset revokedAt) - { - UserId = userId; - SessionId = sessionId; - ChainId = chainId; - RevokedAt = revokedAt; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs index c7d08746..a442a7d9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs @@ -16,27 +16,12 @@ public async Task DispatchAsync(IAuthEventContext context) switch (context) { - case SessionCreatedContext c: - if (_events.OnSessionCreated != null) - await SafeInvoke(() => _events.OnSessionCreated(c)); - break; - - case SessionRefreshedContext c: - if (_events.OnSessionRefreshed != null) - await SafeInvoke(() => _events.OnSessionRefreshed(c)); - break; - - case SessionRevokedContext c: - if (_events.OnSessionRevoked != null) - await SafeInvoke(() => _events.OnSessionRevoked(c)); - break; - - case UserLoggedInContext c: + case UserLoggedInContext c: if (_events.OnUserLoggedIn != null) await SafeInvoke(() => _events.OnUserLoggedIn(c)); break; - case UserLoggedOutContext c: + case UserLoggedOutContext c: if (_events.OnUserLoggedOut != null) await SafeInvoke(() => _events.OnUserLoggedOut(c)); break; @@ -48,5 +33,4 @@ 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 index fadd610d..9162c9fb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Events; /// /// Provides an optional, application-wide event hook system for UltimateAuth. @@ -28,29 +30,21 @@ public class UAuthEvents /// public Func? OnAnyEvent { get; set; } - /// - /// Fired when a new session is created (login or device bootstrap). - /// - public Func, Task>? OnSessionCreated { get; set; } - - /// - /// Fired when an existing session is refreshed and rotated. - /// - public Func, Task>? OnSessionRefreshed { get; set; } - - /// - /// Fired when a specific session is revoked. - /// - public Func, Task>? OnSessionRevoked { get; set; } - /// /// Fired when a user successfully completes the login process. /// Note: separate from SessionCreated; this is a higher-level event. /// - public Func, Task>? OnUserLoggedIn { get; set; } + public Func? OnUserLoggedIn { get; set; } /// /// Fired when a user logs out or all sessions for the user are revoked. /// - public Func, Task>? OnUserLoggedOut { get; set; } + 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 index 54ef844b..e882818a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs @@ -1,4 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Events; +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. @@ -14,28 +17,30 @@ /// - integrating with SIEM or monitoring systems /// /// NOTE: -/// This event is distinct from . +/// 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 sealed class UserLoggedInContext : IAuthEventContext { - /// - /// Gets the identifier of the user who has logged in. - /// - public TUserId UserId { get; } - - /// - /// Gets the timestamp at which the login event occurred. - /// + public TenantKey Tenant { get; } + public UserKey UserKey { get; } public DateTimeOffset LoggedInAt { get; } - /// - /// Initializes a new instance of the class. - /// - public UserLoggedInContext(TUserId userId, DateTimeOffset at) + public DeviceContext? Device { get; } + public AuthSessionId? SessionId { get; } + + public UserLoggedInContext( + TenantKey tenant, + UserKey userKey, + DateTimeOffset at, + DeviceContext? device = null, + AuthSessionId? sessionId = null) { - UserId = userId; + 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 index 85278192..1d7f46ac 100644 --- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs @@ -1,14 +1,16 @@ -namespace CodeBeam.UltimateAuth.Core.Events; +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. +/// 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 , which targets a specific -/// session, this event reflects a higher-level “user has logged out” state and may +/// 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: @@ -17,24 +19,26 @@ /// - triggering notifications (e.g., “You have logged out from device X”) /// - integrating with analytics or SIEM systems /// -public sealed class UserLoggedOutContext : IAuthEventContext +public sealed class UserLoggedOutContext : IAuthEventContext { - /// - /// Gets the identifier of the user who has logged out. - /// - public TUserId UserId { get; } - - /// - /// Gets the timestamp at which the logout occurred. - /// + public TenantKey Tenant { get; } + public UserKey UserKey { get; } public DateTimeOffset LoggedOutAt { get; } - /// - /// Initializes a new instance of the class. - /// - public UserLoggedOutContext(TUserId userId, DateTimeOffset at) + public AuthSessionId? SessionId { get; } + public LogoutReason Reason { get; } + + public UserLoggedOutContext( + TenantKey tenant, + UserKey userKey, + DateTimeOffset at, + LogoutReason reason, + AuthSessionId? sessionId = null) { - UserId = userId; + Tenant = tenant; + UserKey = userKey; LoggedOutAt = at; + Reason = reason; + SessionId = sessionId; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs index d54703ec..ffc2f0cb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -19,6 +19,25 @@ public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, st return new ClaimsPrincipal(identity); } + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, UserKey? userKey, string authenticationType) + { + if (snapshot == null) + return new ClaimsPrincipal(new ClaimsIdentity()); + + var claims = snapshot.Claims.SelectMany(kv => kv.Value.Select(v => new Claim(kv.Key, v))).ToList(); + + if (userKey is not null) + { + var value = userKey.Value.ToString(); + claims.Add(new Claim(ClaimTypes.Name, value)); + claims.Add(new Claim(ClaimTypes.NameIdentifier, value)); + } + + var identity = new ClaimsIdentity(claims, authenticationType, ClaimTypes.Name, ClaimTypes.Role); + return new ClaimsPrincipal(identity); + } + + /// /// Converts an ASP.NET Core ClaimsPrincipal into a ClaimsSnapshot. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs index 17204ca2..ea8e2fea 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs @@ -9,53 +9,42 @@ namespace CodeBeam.UltimateAuth.Core.Extensions; -// TODO: Check it before stable release /// -/// Provides extension methods for registering UltimateAuth core services into -/// the application's dependency injection container. +/// 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. +/// 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()). +/// 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 { - /// - /// Registers UltimateAuth services using configuration binding (e.g., appsettings.json). - /// - /// The provided configuration section must contain valid UltimateAuthOptions and nested - /// Session, Token, PKCE, and MultiTenant configuration sections. Validation occurs - /// at application startup via IValidateOptions. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services, IConfiguration configurationSection) + public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action? configure = null) { - services.Configure(configurationSection); - return services.AddUltimateAuthInternal(); - } + ArgumentNullException.ThrowIfNull(services); - /// - /// Registers UltimateAuth services using programmatic configuration. - /// This is useful when settings are derived dynamically or are not stored - /// in appsettings.json. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action configure) - { - services.Configure(configure); - return services.AddUltimateAuthInternal(); - } + 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); + }); - /// - /// Registers UltimateAuth services using default empty configuration. - /// Intended for advanced or fully manual scenarios where options will be - /// configured later or overridden by the server layer. - /// - public static IServiceCollection AddUltimateAuth(this IServiceCollection services) - { - services.Configure(_ => { }); return services.AddUltimateAuthInternal(); } @@ -65,24 +54,24 @@ public static IServiceCollection AddUltimateAuth(this IServiceCollection service /// Core-level invariant validation. /// Server layer may add additional validators. /// NOTE: - /// This method does NOT register session stores or server-side services. + /// This method does not register session stores or server-side services. /// A server project must explicitly call: - /// - /// services.AddUltimateAuthSessionStore'TStore'(); - /// + /// "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>(); - // Nested options are bound automatically by the options binder. - // Server layer may override or extend these settings. - services.AddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); 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/DevicePresenceInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs similarity index 52% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs index b9498b81..7f8b6be3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs @@ -3,14 +3,13 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; -public sealed class DevicePresenceInvariant : IAuthorityInvariant +public sealed class TenantResolvedInvariant : IAuthorityInvariant { public AccessDecisionResult Decide(AuthContext context) { - if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) + if (context.Tenant.IsUnresolved) { - if (context.Device is null) - return AccessDecisionResult.Deny("Device information is required."); + return AccessDecisionResult.Deny("Tenant is not resolved."); } return AccessDecisionResult.Allow(); diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs index db6570c5..0a4da433 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs @@ -4,6 +4,9 @@ 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) diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs index c04cafce..820f43ca 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs @@ -1,22 +1,7 @@ namespace CodeBeam.UltimateAuth.Core.MultiTenancy; -/// -/// Resolves the tenant id from the request path. -/// Example pattern: /t/{tenantId}/... → returns the extracted tenant id. -/// public sealed class PathTenantResolver : ITenantIdResolver { - private readonly string _prefix; - - /// - /// Creates a resolver that looks for tenant ids under a specific URL prefix. - /// Default prefix is "t", meaning URLs like /t/foo/api will resolve "foo". - /// - public PathTenantResolver(string prefix = "t") - { - _prefix = prefix; - } - /// /// Extracts the tenant id from the request path, if present. /// Returns null when the prefix is not matched or the path is insufficient. @@ -29,11 +14,10 @@ public PathTenantResolver(string prefix = "t") var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - // Format: /{prefix}/{tenantId}/... - if (segments.Length >= 2 && segments[0] == _prefix) - return Task.FromResult(segments[1]); + // Format: /{tenant}/... + if (segments.Length >= 1) + return Task.FromResult(segments[0]); return Task.FromResult(null); } - } 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/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 0912e65a..60621b08 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -1,20 +1,26 @@ -namespace CodeBeam.UltimateAuth.Core.Options; +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. +/// 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 temporarily locked out. + /// 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; } = 5; + public int MaxFailedAttempts { get; set; } = 10; /// - /// Duration (in minutes) for which the user is locked out - /// after exceeding . + /// Duration (in minutes) for which the user is locked out after exceeding . /// public int LockoutMinutes { get; set; } = 15; + + internal UAuthLoginOptions Clone() => new() + { + MaxFailedAttempts = MaxFailedAttempts, + LockoutMinutes = LockoutMinutes + }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs index 2d76b691..ee0b0409 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs @@ -1,5 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Options; +// TODO: Add Tenant registration /// /// Multi-tenancy configuration for UltimateAuth. /// Controls whether tenants are required, how they are resolved, @@ -13,18 +14,11 @@ public sealed class UAuthMultiTenantOptions /// 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, a tenant id returned by resolver does NOT need to be known beforehand. - /// If false, unknown tenants must be explicitly registered. - /// (Useful for multi-tenant SaaS with dynamic tenant provisioning) - /// - public bool AllowUnknownTenants { get; set; } = true; + ///// + ///// 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. @@ -32,12 +26,11 @@ public sealed class UAuthMultiTenantOptions /// 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; } = true; + public bool EnableRoute { get; set; } = false; public bool EnableHeader { get; set; } = false; public bool EnableDomain { get; set; } = false; @@ -47,13 +40,10 @@ public sealed class UAuthMultiTenantOptions internal UAuthMultiTenantOptions Clone() => new() { Enabled = Enabled, - RequireTenant = RequireTenant, - AllowUnknownTenants = AllowUnknownTenants, NormalizeToLowercase = NormalizeToLowercase, EnableRoute = EnableRoute, EnableHeader = EnableHeader, EnableDomain = EnableDomain, HeaderName = HeaderName }; - } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs deleted file mode 100644 index ec416dfc..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptionsValidator.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Extensions.Options; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Core.Options; - -internal sealed class UAuthMultiTenantOptionsValidator : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, UAuthMultiTenantOptions options) - { - if (!options.Enabled) - { - if (options.RequireTenant) - { - return ValidateOptionsResult.Fail("RequireTenant cannot be true when multi-tenancy is disabled."); - } - - return ValidateOptionsResult.Success; - } - - if (!options.EnableRoute && - !options.EnableHeader && - !options.EnableDomain) - { - return ValidateOptionsResult.Fail( - "Multi-tenancy is enabled but no tenant resolver is active " + - "(route, header, or domain)."); - } - - return ValidateOptionsResult.Success; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs index a65a1d51..ac273a50 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs @@ -1,18 +1,17 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Events; +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. +/// 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(). +/// 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. @@ -41,20 +40,11 @@ public sealed class UAuthOptions /// Event hooks raised during authentication lifecycle events /// such as login, logout, session creation, refresh, or revocation. /// - public UAuthEvents UAuthEvents { get; set; } = new(); + public UAuthEvents Events { get; set; } = new(); /// /// Multi-tenancy configuration controlling how tenants are resolved, /// validated, and optionally enforced. /// public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); - - /// - /// Provides converters used to normalize and serialize TUserId - /// across the system (sessions, stores, tokens, logging). - /// - public IUserIdConverterResolver? UserIdConverters { get; set; } - - public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; - public bool AutoDetectClientProfile { get; set; } = true; } 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 index ab13f894..6549acc4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs @@ -14,12 +14,8 @@ public sealed class UAuthPkceOptions /// public int AuthorizationCodeLifetimeSeconds { get; set; } = 120; - public int MaxVerificationAttempts { get; set; } = 5; - internal UAuthPkceOptions Clone() => new() { - AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds, - MaxVerificationAttempts = MaxVerificationAttempts, + AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds }; - } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 35fd2631..007cad76 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -4,14 +4,13 @@ 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 +/// 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. +/// These values influence how sessions are created, refreshed, expired, revoked, and grouped into device chains. /// public sealed class UAuthSessionOptions { @@ -50,45 +49,78 @@ public sealed class UAuthSessionOptions /// /// 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. + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. /// 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() @@ -107,5 +139,4 @@ public sealed class UAuthSessionOptions EnableIpBinding = EnableIpBinding, EnableUserAgentBinding = EnableUserAgentBinding }; - } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs deleted file mode 100644 index c757772b..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptionsValidator.cs +++ /dev/null @@ -1,99 +0,0 @@ -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/UAuthTokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs index 9afd48d0..7d9aeacd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -75,5 +75,4 @@ public sealed class UAuthTokenOptions 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..f4cb1f97 --- /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.LockoutMinutes <= 0) + 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/UAuthOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs index aa7dff30..12119bb6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs @@ -21,6 +21,12 @@ public ValidateOptionsResult Validate(string? name, UAuthOptions options) 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); diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthPkceOptionsValidator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthPkceOptionsValidator.cs 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/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs similarity index 50% rename from src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs rename to src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs index 7d374cb0..33881151 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs @@ -14,33 +14,33 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) if (options.AccessTokenLifetime <= TimeSpan.Zero) errors.Add("Token.AccessTokenLifetime must be greater than zero."); - if (options.RefreshTokenLifetime <= TimeSpan.Zero) - errors.Add("Token.RefreshTokenLifetime 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.RefreshTokenLifetime <= options.AccessTokenLifetime) + errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); + } if (options.IssueJwt) { - if (string.IsNullOrWhiteSpace(options.Issuer)) // TODO: Min 3 chars - errors.Add("Token.Issuer must not be empty when IssueJwt = true."); + 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)) - errors.Add("Token.Audience must not be empty when IssueJwt = true."); + 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 (128-bit entropy)."); - } + errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); - if (options.IssueRefresh && options.RefreshTokenLifetime <= TimeSpan.Zero) - { - errors.Add("RefreshTokenLifetime must be set when IssueRefresh is enabled."); + 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/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/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 index 629c25a9..7e4b3841 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Options; - -namespace CodeBeam.UltimateAuth.Core.Runtime; +namespace CodeBeam.UltimateAuth.Core.Runtime; public sealed class UAuthProductInfo { @@ -8,9 +6,6 @@ public sealed class UAuthProductInfo public string Version { get; init; } = default!; public string? InformationalVersion { get; init; } - public UAuthClientProfile ClientProfile { get; init; } - public bool ClientProfileAutoDetected { 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 index 90de44f8..99f027c2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs @@ -16,9 +16,6 @@ public UAuthProductInfoProvider(IOptions options) { Version = asm.GetName().Version?.ToString(3) ?? "unknown", InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, - - ClientProfile = options.Value.ClientProfile, - ClientProfileAutoDetected = options.Value.AutoDetectClientProfile, StartedAt = DateTimeOffset.UtcNow }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs index 484c38f5..cd5c4e55 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -1,5 +1,7 @@ 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; @@ -8,10 +10,12 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class AccessContextFactory : IAccessContextFactory { private readonly IUserRoleStore _roleStore; + private readonly IUserIdConverterResolver _converterResolver; - public AccessContextFactory(IUserRoleStore roleStore) + public AccessContextFactory(IUserRoleStore roleStore, IUserIdConverterResolver converterResolver) { _roleStore = roleStore; + _converterResolver = converterResolver; } public async Task CreateAsync(AuthFlowContext authFlow, string action, string resource, string? resourceId = null, IDictionary? attributes = null, CancellationToken ct = default) @@ -45,22 +49,31 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, attrs["roles"] = roles; } - return new AccessContext + UserKey? targetUserKey = null; + + if (!string.IsNullOrWhiteSpace(resourceId)) { - ActorUserKey = authFlow.UserKey, - ActorTenant = authFlow.Tenant, - IsAuthenticated = authFlow.IsAuthenticated, - IsSystemActor = authFlow.Tenant.IsSystem, + var converter = _converterResolver.GetConverter(null); + + if (!converter.TryFromString(resourceId, out var parsed)) + throw new InvalidOperationException("Invalid resource user id."); - Resource = resource, - ResourceId = resourceId, - ResourceTenant = resourceTenant, + var canonical = converter.ToCanonicalString(parsed); + targetUserKey = UserKey.FromString(canonical); + } - Action = action, - Attributes = attrs.Count > 0 + return new AccessContext( + actorUserKey: authFlow.UserKey, + actorTenant: authFlow.Tenant, + isAuthenticated: authFlow.IsAuthenticated, + isSystemActor: authFlow.Tenant.IsSystem, + 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/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs index f68bee45..50bd4e05 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -3,6 +3,7 @@ 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; @@ -24,6 +25,8 @@ public sealed class AuthFlowContext public EffectiveUAuthServerOptions EffectiveOptions { get; } public EffectiveAuthResponse Response { get; } + public ReturnUrlInfo ReturnUrlInfo { get; } + public PrimaryTokenKind PrimaryTokenKind { get; } // Helpers @@ -46,7 +49,8 @@ internal AuthFlowContext( UAuthServerOptions originalOptions, EffectiveUAuthServerOptions effectiveOptions, EffectiveAuthResponse response, - PrimaryTokenKind primaryTokenKind) + PrimaryTokenKind primaryTokenKind, + ReturnUrlInfo returnUrlInfo) { if (tenantKey.IsUnresolved) throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); @@ -66,5 +70,7 @@ internal AuthFlowContext( 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 index 3521182d..bbc26679 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -50,13 +51,20 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp 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 = _deviceResolver.Resolve(ctx); var deviceContext = _deviceContextFactory.Create(deviceInfo); + var returnUrl = ctx.GetReturnUrl(); + var returnUrlInfo = ReturnUrlParser.Parse(returnUrl); SessionSecurityContext? sessionSecurityContext = null; @@ -93,8 +101,48 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp originalOptions, effectiveOptions, response, - primaryTokenKind + 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 + ); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs index 2aff1465..d1679c40 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Auth; @@ -6,4 +7,5 @@ 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); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs index 5f93b4c0..4d3052ca 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs @@ -1,6 +1,8 @@ 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; @@ -23,13 +25,18 @@ public UAuthServerOptions GetOriginal(HttpContext context) } 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(original.Mode, clientProfile, flowType); + var effectiveMode = _modeResolver.Resolve(clientProfile, flowType); var options = original.Clone(); - options.Mode = effectiveMode; - ConfigureDefaults.ApplyModeDefaults(options); + ConfigureDefaults.ApplyModeDefaults(effectiveMode, options); if (original.ModeConfigurations.TryGetValue(effectiveMode, out var configure)) { @@ -42,5 +49,4 @@ public EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowTyp Options = options }; } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs index 22cf6f84..f17a63a6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs @@ -12,5 +12,5 @@ public sealed class EffectiveUAuthServerOptions /// public UAuthServerOptions Options { get; init; } = default!; - public AuthResponseOptions AuthResponse => Options.AuthResponse; + public UAuthResponseOptions AuthResponse => Options.AuthResponse; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs index 8eae3aba..cc3da3d8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class AuthResponseOptionsModeTemplateResolver { - public AuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) + public UAuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) { return mode switch { @@ -20,7 +20,7 @@ public AuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) }; } - private static AuthResponseOptions PureOpaque(AuthFlowType flow) + private static UAuthResponseOptions PureOpaque(AuthFlowType flow) => new() { SessionIdDelivery = new() @@ -44,11 +44,19 @@ private static AuthResponseOptions PureOpaque(AuthFlowType flow) TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } }; - private static AuthResponseOptions Hybrid(AuthFlowType flow) + private static UAuthResponseOptions Hybrid(AuthFlowType flow) => new() { SessionIdDelivery = new() @@ -72,11 +80,19 @@ private static AuthResponseOptions Hybrid(AuthFlowType flow) TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } }; - private static AuthResponseOptions SemiHybrid(AuthFlowType flow) + private static UAuthResponseOptions SemiHybrid(AuthFlowType flow) => new() { SessionIdDelivery = new() @@ -100,11 +116,19 @@ private static AuthResponseOptions SemiHybrid(AuthFlowType flow) TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } }; - private static AuthResponseOptions PureJwt(AuthFlowType flow) + private static UAuthResponseOptions PureJwt(AuthFlowType flow) => new() { SessionIdDelivery = new() @@ -128,7 +152,15 @@ private static AuthResponseOptions PureJwt(AuthFlowType flow) TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, - Login = { RedirectEnabled = true }, - Logout = { RedirectEnabled = true } + + 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 index 8306b0e3..8ae5fcbd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -25,31 +25,19 @@ public EffectiveAuthResponse Resolve(UAuthMode effectiveMode, AuthFlowType flowT // TODO: This is currently implicit Validate(bound); + var redirect = ResolveRedirect(flowType, bound); + return new EffectiveAuthResponse( bound.SessionIdDelivery, bound.AccessTokenDelivery, bound.RefreshTokenDelivery, - - new EffectiveLoginRedirectResponse( - bound.Login.RedirectEnabled, - bound.Login.SuccessRedirect, - bound.Login.FailureRedirect, - bound.Login.FailureQueryKey, - bound.Login.CodeQueryKey, - bound.Login.FailureCodes - ), - - new EffectiveLogoutRedirectResponse( - bound.Logout.RedirectEnabled, - bound.Logout.RedirectUrl, - bound.Logout.AllowReturnUrlOverride - ) + redirect ); } - private static AuthResponseOptions BindCookies(AuthResponseOptions response, UAuthServerOptions server) + private static UAuthResponseOptions BindCookies(UAuthResponseOptions response, UAuthServerOptions server) { - return new AuthResponseOptions + return new UAuthResponseOptions { SessionIdDelivery = Bind(response.SessionIdDelivery, server), AccessTokenDelivery = Bind(response.AccessTokenDelivery, server), @@ -75,7 +63,7 @@ private static CredentialResponseOptions Bind(CredentialResponseOptions delivery return delivery.WithCookie(cookie); } - private static void Validate(AuthResponseOptions response) + private static void Validate(UAuthResponseOptions response) { ValidateDelivery(response.SessionIdDelivery); ValidateDelivery(response.AccessTokenDelivery); @@ -90,4 +78,17 @@ private static void ValidateDelivery(CredentialResponseOptions delivery) } } + 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 index 16e75fdb..a9726933 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -8,16 +8,18 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class ClientProfileAuthResponseAdapter { - public AuthResponseOptions Adapt(AuthResponseOptions template, UAuthClientProfile clientProfile, UAuthMode effectiveMode, EffectiveUAuthServerOptions effectiveOptions) + public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProfile clientProfile, UAuthMode effectiveMode, EffectiveUAuthServerOptions effectiveOptions) { - return new AuthResponseOptions + var configured = effectiveOptions.Options.AuthResponse; + + return new UAuthResponseOptions { SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, CredentialKind.Session, clientProfile), AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, CredentialKind.AccessToken, clientProfile), RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, CredentialKind.RefreshToken, clientProfile), - Login = template.Login, - Logout = template.Logout + Login = MergeLogin(template.Login, configured.Login), + Logout = MergeLogout(template.Logout, configured.Logout) }; } @@ -51,4 +53,28 @@ private static CredentialResponseOptions ToHeader(CredentialResponseOptions orig }; } + 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 index 35422857..90865473 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs @@ -6,11 +6,8 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class EffectiveAuthModeResolver : IEffectiveAuthModeResolver { - public UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType) + public UAuthMode Resolve(UAuthClientProfile clientProfile, AuthFlowType flowType) { - if (configuredMode.HasValue) - return configuredMode.Value; - return clientProfile switch { UAuthClientProfile.BlazorServer => UAuthMode.PureOpaque, diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs index b0280bed..7a82917d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs @@ -2,10 +2,22 @@ namespace CodeBeam.UltimateAuth.Server.Auth; -public sealed record EffectiveAuthResponse( - CredentialResponseOptions SessionIdDelivery, - CredentialResponseOptions AccessTokenDelivery, - CredentialResponseOptions RefreshTokenDelivery, - EffectiveLoginRedirectResponse Login, - EffectiveLogoutRedirectResponse Logout -); +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/EffectiveLoginRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs deleted file mode 100644 index 8408cadd..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLoginRedirectResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Auth; - -public sealed record EffectiveLoginRedirectResponse -( - bool RedirectEnabled, - string SuccessPath, - string FailurePath, - string FailureQueryKey, - string CodeQueryKey, - IReadOnlyDictionary FailureCodes -); 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..c81d6aa6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs @@ -0,0 +1,52 @@ +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 string? FailureQueryKey { get; } + public IReadOnlyDictionary? FailureCodes { get; } + public bool AllowReturnUrlOverride { get; } + + public EffectiveRedirectResponse( + bool enabled, + string? successPath, + string? failurePath, + string? failureQueryKey, + IReadOnlyDictionary? failureCodes, + bool allowReturnUrlOverride) + { + Enabled = enabled; + SuccessPath = successPath; + FailurePath = failurePath; + FailureQueryKey = failureQueryKey; + FailureCodes = failureCodes; + AllowReturnUrlOverride = allowReturnUrlOverride; + } + + public static readonly EffectiveRedirectResponse Disabled = new(false, null, null, null, null, false); + + public static EffectiveRedirectResponse FromLogin(LoginRedirectOptions login) + => new( + login.RedirectEnabled, + login.SuccessRedirect, + login.FailureRedirect, + login.FailureQueryKey, + login.FailureCodes, + login.AllowReturnUrlOverride + ); + + public static EffectiveRedirectResponse FromLogout(LogoutRedirectOptions logout) + => new( + logout.RedirectEnabled, + logout.RedirectUrl, + null, + null, + null, + logout.AllowReturnUrlOverride + ); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs index 2477a12d..71969091 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs @@ -6,5 +6,5 @@ namespace CodeBeam.UltimateAuth.Server.Auth; public interface IEffectiveAuthModeResolver { - UAuthMode Resolve(UAuthMode? configuredMode, UAuthClientProfile clientProfile, AuthFlowType flowType); + UAuthMode Resolve(UAuthClientProfile clientProfile, AuthFlowType flowType); } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs index e2f56a2c..98add804 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -58,7 +58,7 @@ protected override async Task HandleAuthenticateAsync() if (!result.IsValid || result.UserKey is null) return AuthenticateResult.NoResult(); - var principal = result.Claims.ToClaimsPrincipal(UAuthCookieDefaults.AuthenticationScheme); + var principal = result.Claims.ToClaimsPrincipal(result.UserKey, UAuthCookieDefaults.AuthenticationScheme); return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthCookieDefaults.AuthenticationScheme)); } } \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs index 5259492a..91617a35 100644 --- a/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs @@ -1,20 +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; +//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; +//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 +//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")); +// services.Configure(configuration.GetSection("UltimateAuth:Server")); - return new UltimateAuthServerBuilder(services); - } -} +// return new UltimateAuthServerBuilder(services); +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 5b5361e9..ab0c4530 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -17,14 +17,14 @@ public sealed class LoginEndpointHandler : ILoginEndpointHandler private readonly IUAuthFlowService _flowService; private readonly IClock _clock; private readonly ICredentialResponseWriter _credentialResponseWriter; - private readonly AuthRedirectResolver _redirectResolver; + private readonly IAuthRedirectResolver _redirectResolver; public LoginEndpointHandler( IAuthFlowContextAccessor authFlow, IUAuthFlowService flowService, IClock clock, ICredentialResponseWriter credentialResponseWriter, - AuthRedirectResolver redirectResolver) + IAuthRedirectResolver redirectResolver) { _authFlow = authFlow; _flowService = flowService; @@ -37,10 +37,6 @@ public async Task LoginAsync(HttpContext ctx) { var authFlow = _authFlow.Current; - var shouldIssueTokens = - authFlow.Response.AccessTokenDelivery.Mode != TokenResponseMode.None || - authFlow.Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; - if (!ctx.Request.HasFormContentType) return Results.BadRequest("Invalid content type."); @@ -50,7 +46,13 @@ public async Task LoginAsync(HttpContext ctx) var secret = form["Secret"].ToString(); if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret)) - return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, authFlow.OriginalOptions); + { + var decisionFailureInvalid = _redirectResolver.ResolveFailure(authFlow, ctx, AuthFailureReason.InvalidCredentials); + + return decisionFailureInvalid.Enabled + ? Results.Redirect(decisionFailureInvalid.TargetUrl!) + : Results.Unauthorized(); + } var flowRequest = new LoginRequest { @@ -59,13 +61,19 @@ public async Task LoginAsync(HttpContext ctx) Tenant = authFlow.Tenant, At = _clock.UtcNow, Device = authFlow.Device, - RequestTokens = shouldIssueTokens + RequestTokens = authFlow.AllowsTokenIssuance }; var result = await _flowService.LoginAsync(authFlow, flowRequest, ctx.RequestAborted); if (!result.IsSuccess) - return RedirectFailure(ctx, result.FailureReason ?? AuthFailureReason.Unknown, authFlow.OriginalOptions); + { + var decisionFailure = _redirectResolver.ResolveFailure(authFlow, ctx, result.FailureReason ?? AuthFailureReason.Unknown); + + return decisionFailure.Enabled + ? Results.Redirect(decisionFailure.TargetUrl!) + : Results.Unauthorized(); + } if (result.SessionId is AuthSessionId sessionId) { @@ -82,33 +90,10 @@ public async Task LoginAsync(HttpContext ctx) _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); } - if (authFlow.Response.Login.RedirectEnabled) - { - var redirectUrl = _redirectResolver.ResolveRedirect(ctx, authFlow.Response.Login.SuccessPath); - return Results.Redirect(redirectUrl); - } - - return Results.Ok(); - } + var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); - private IResult RedirectFailure(HttpContext ctx, AuthFailureReason reason, UAuthServerOptions options) - { - var login = options.AuthResponse.Login; - - var code = - login.FailureCodes != null && - login.FailureCodes.TryGetValue(reason, out var mapped) - ? mapped - : "failed"; - - var redirectUrl = _redirectResolver.ResolveRedirect( - ctx, - login.FailureRedirect, - new Dictionary - { - [login.FailureQueryKey] = code - }); - - return Results.Redirect(redirectUrl); + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index d99f9e1e..6106dce2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -2,7 +2,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; @@ -16,9 +15,9 @@ public sealed class LogoutEndpointHandler : ILogoutEndpointHandler private readonly IUAuthFlowService _flow; private readonly IClock _clock; private readonly IUAuthCookieManager _cookieManager; - private readonly AuthRedirectResolver _redirectResolver; + private readonly IAuthRedirectResolver _redirectResolver; - public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, AuthRedirectResolver redirectResolver) + public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; @@ -29,13 +28,13 @@ public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowSer public async Task LogoutAsync(HttpContext ctx) { - var auth = _authContext.Current; + var authFlow = _authContext.Current; - if (auth.Session is SessionSecurityContext session) + if (authFlow.Session is SessionSecurityContext session) { var request = new LogoutRequest { - Tenant = auth.Tenant, + Tenant = authFlow.Tenant, SessionId = session.SessionId, At = _clock.UtcNow, }; @@ -43,20 +42,15 @@ public async Task LogoutAsync(HttpContext ctx) await _flow.LogoutAsync(request, ctx.RequestAborted); } - DeleteIfCookie(ctx, auth.Response.SessionIdDelivery); - DeleteIfCookie(ctx, auth.Response.RefreshTokenDelivery); - DeleteIfCookie(ctx, auth.Response.AccessTokenDelivery); + DeleteIfCookie(ctx, authFlow.Response.SessionIdDelivery); + DeleteIfCookie(ctx, authFlow.Response.RefreshTokenDelivery); + DeleteIfCookie(ctx, authFlow.Response.AccessTokenDelivery); - if (auth.Response.Logout.RedirectEnabled) - { - var redirectUrl = _redirectResolver.ResolveRedirect(ctx, auth.Response.Logout.RedirectPath); - return Results.Redirect(redirectUrl); - } + var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); - return Results.Ok(new LogoutResponse - { - Success = true - }); + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(new LogoutResponse { Success = true }); } private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery) @@ -69,5 +63,4 @@ private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery) _cookieManager.Delete(ctx, delivery.Cookie.Name); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index f53a6bb2..c54c0328 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.Abstractions; 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.Flows; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; using Microsoft.AspNetCore.Http; @@ -20,9 +20,9 @@ internal sealed class PkceEndpointHandler : IPkceEndpointHandler private readonly IAuthStore _authStore; private readonly IPkceAuthorizationValidator _validator; private readonly IClock _clock; - private readonly UAuthPkceOptions _pkceOptions; + private readonly UAuthServerOptions _options; private readonly ICredentialResponseWriter _credentialResponseWriter; - private readonly AuthRedirectResolver _redirectResolver; + private readonly IAuthRedirectResolver _redirectResolver; public PkceEndpointHandler( IAuthFlowContextAccessor authContext, @@ -30,16 +30,16 @@ public PkceEndpointHandler( IAuthStore authStore, IPkceAuthorizationValidator validator, IClock clock, - IOptions pkceOptions, + IOptions options, ICredentialResponseWriter credentialResponseWriter, - AuthRedirectResolver redirectResolver) + IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; _authStore = authStore; _validator = validator; _clock = clock; - _pkceOptions = pkceOptions.Value; + _options = options.Value; _credentialResponseWriter = credentialResponseWriter; _redirectResolver = redirectResolver; } @@ -70,14 +70,13 @@ public async Task AuthorizeAsync(HttpContext ctx) deviceId: string.Empty // TODO: Fix here with device binding ); - var expiresAt = _clock.UtcNow.AddSeconds(_pkceOptions.AuthorizationCodeLifetimeSeconds); + var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds); var artifact = new PkceAuthorizationArtifact( authorizationCode: authorizationCode, codeChallenge: request.CodeChallenge, challengeMethod: PkceChallengeMethod.S256, expiresAt: expiresAt, - maxAttempts: _pkceOptions.MaxVerificationAttempts, context: snapshot ); @@ -86,7 +85,7 @@ public async Task AuthorizeAsync(HttpContext ctx) return Results.Ok(new PkceAuthorizeResponse { AuthorizationCode = authorizationCode.Value, - ExpiresIn = _pkceOptions.AuthorizationCodeLifetimeSeconds + ExpiresIn = _options.Pkce.AuthorizationCodeLifetimeSeconds }); } @@ -159,13 +158,11 @@ public async Task CompleteAsync(HttpContext ctx) _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); } - if (authContext.Response.Login.RedirectEnabled) - { - var redirectUrl = request.ReturnUrl ?? _redirectResolver.ResolveRedirect(ctx, authContext.Response.Login.SuccessPath); - return Results.Redirect(redirectUrl); - } + var decision = _redirectResolver.ResolveSuccess(authContext, ctx); - return Results.Ok(); + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(); } private static async Task ReadPkceAuthorizeRequestAsync(HttpContext ctx) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 546358de..929cd276 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -12,10 +12,14 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; // TODO: Add endpoint based guards public class UAuthEndpointRegistrar : IAuthEndpointRegistrar { + + // 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.RoutePrefix.TrimStart('/'); + string basePrefix = options.Endpoints.BasePath.TrimStart('/'); bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute; RouteGroupBuilder group = useRouteTenant @@ -24,7 +28,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.AddEndpointFilter(); - if (options.EnableLoginEndpoints != false) + if (options.Endpoints.Login != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); @@ -42,7 +46,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); } - if (options.EnablePkceEndpoints != false) + if (options.Endpoints.Pkce != false) { var pkce = group.MapGroup("/pkce"); @@ -53,7 +57,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); } - if (options.EnableTokenEndpoints != false) + if (options.Endpoints.Token != false) { var token = group.MapGroup(""); @@ -70,7 +74,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); } - if (options.EnableSessionEndpoints != false) + if (options.Endpoints.Session != false) { var session = group.MapGroup("/session"); @@ -87,7 +91,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); } - var user = group.MapGroup(""); + //var user = group.MapGroup(""); var users = group.MapGroup("/users"); var adminUsers = group.MapGroup("/admin/users"); @@ -103,7 +107,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); //} - if (options.EnableUserLifecycleEndpoints != false) + if (options.Endpoints.UserLifecycle != false) { users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); @@ -119,7 +123,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); } - if (options.EnableUserProfileEndpoints != false) + if (options.Endpoints.UserProfile != false) { users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); @@ -134,7 +138,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); } - if (options.EnableUserIdentifierEndpoints != false) + if (options.Endpoints.UserIdentifier != false) { users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); @@ -180,7 +184,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); } - if (options.EnableCredentialsEndpoints != false) + if (options.Endpoints.Credentials != false) { var credentials = group.MapGroup("/credentials"); var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); @@ -226,7 +230,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); } - if (options.EnableAuthorizationEndpoints != false) + if (options.Endpoints.Authorization != false) { var authz = group.MapGroup("/authorization"); var adminAuthz = group.MapGroup("/admin/authorization"); @@ -248,5 +252,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RemoveRoleAsync(userKey, 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/Extensions/AuthFlowContextExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs index e48c09d3..12ff84cf 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -10,6 +9,7 @@ public static AuthContext ToAuthContext(this AuthFlowContext flow, DateTimeOffse { return new AuthContext { + ClientProfile = flow.ClientProfile, Tenant = flow.Tenant, Operation = flow.FlowType.ToAuthOperation(), Mode = flow.EffectiveMode, @@ -18,21 +18,4 @@ public static AuthContext ToAuthContext(this AuthFlowContext flow, DateTimeOffse Session = flow.Session }; } - - public static AuthFlowContext WithClientProfile(this AuthFlowContext flow, UAuthClientProfile profile) - { - return new AuthFlowContext( - flow.FlowType, - profile, - flow.EffectiveMode, - flow.Device, - flow.Tenant, - flow.IsAuthenticated, - flow.UserKey, - flow.Session, - flow.OriginalOptions, - flow.EffectiveOptions, - flow.Response, - flow.PrimaryTokenKind); - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs index f27f3828..c4023a5e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs @@ -11,15 +11,16 @@ public static class EndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapUAuthEndpoints(this IEndpointRouteBuilder endpoints) { - using var scope = endpoints.ServiceProvider.CreateScope(); - var registrar = scope.ServiceProvider.GetRequiredService(); - var options = scope.ServiceProvider.GetRequiredService>().Value; - - // Root group ("/") + var registrar = endpoints.ServiceProvider.GetRequiredService(); + var options = endpoints.ServiceProvider.GetRequiredService>().Value; var rootGroup = endpoints.MapGroup(""); - registrar.MapEndpoints(rootGroup, options); + if (endpoints is WebApplication app) + { + options.OnConfigureEndpoints?.Invoke(app); + } + return endpoints; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs new file mode 100644 index 00000000..175864d5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +internal static class HttpContextReturnUrlExtensions +{ + public static string? GetReturnUrl(this HttpContext ctx) + { + if (ctx.Request.HasFormContentType && ctx.Request.Form.TryGetValue("return_url", out var form)) + { + return form.ToString(); + } + + if (ctx.Request.Query.TryGetValue("return_url", out var query)) + { + return query.ToString(); + } + + return null; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index dc44adab..7a37e3a5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -2,10 +2,12 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; 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; @@ -13,15 +15,14 @@ using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Cookies; 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.Runtime; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -31,59 +32,74 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services) + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action? configure = null) { + ArgumentNullException.ThrowIfNull(services); services.AddUltimateAuth(); - AddUsersInternal(services); - AddCredentialsInternal(services); - AddAuthorizationInternal(services); - AddUltimateAuthPolicies(services); - return services.AddUltimateAuthServerInternal(); - } - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) - { - services.AddUltimateAuth(configuration); AddUsersInternal(services); AddCredentialsInternal(services); AddAuthorizationInternal(services); AddUltimateAuthPolicies(services); - services.Configure(configuration.GetSection("UltimateAuth:Server")); - return services.AddUltimateAuthServerInternal(); - } + 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 + }); - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action configure) - { - services.AddUltimateAuth(); - AddUsersInternal(services); - AddCredentialsInternal(services); - AddAuthorizationInternal(services); - AddUltimateAuthPolicies(services); - services.Configure(configure); + services.AddUltimateAuthServerInternal(); - return services.AddUltimateAuthServerInternal(); + return services; } private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) { + services.AddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(sp => + 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.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 => @@ -114,9 +130,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddScoped(); services.AddScoped(); - // Public resolver services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); services.TryAddScoped(); @@ -124,17 +138,6 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); - // TODO: Allow custom cookie manager via options - //services.AddSingleton(); - //if (options.CustomCookieManagerType is not null) - //{ - // services.AddSingleton(typeof(IUAuthSessionCookieManager), options.CustomCookieManagerType); - //} - //else - //{ - // services.AddSingleton(); - //} - services.TryAddScoped(); services.TryAddScoped(); @@ -156,6 +159,13 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -166,7 +176,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); @@ -184,9 +194,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddScoped(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); @@ -247,8 +257,6 @@ internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollecti return new UAuthAccessAuthority(invariants, globalPolicies); }); - services.TryAddScoped(); - return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs index defffe30..141c1628 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -1,4 +1,8 @@ -namespace CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Flows; /// /// Default implementation of the login authority. @@ -6,13 +10,15 @@ /// public sealed class LoginAuthority : ILoginAuthority { - public LoginDecision Decide(LoginDecisionContext context) + private readonly UAuthLoginOptions _options; + + public LoginAuthority(IOptions options) { - if (!context.CredentialsValid) - { - return LoginDecision.Deny("Invalid credentials."); - } + _options = options.Value.Login; + } + public LoginDecision Decide(LoginDecisionContext context) + { if (!context.UserExists || context.UserKey is null) { return LoginDecision.Deny("Invalid credentials."); @@ -28,6 +34,11 @@ public LoginDecision Decide(LoginDecisionContext context) return LoginDecision.Challenge("reauth_required"); } + if (!context.CredentialsValid) + { + return LoginDecision.Deny("Invalid credentials."); + } + return LoginDecision.Allow(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 40e008dc..77488ee0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -2,12 +2,16 @@ 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.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; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -16,33 +20,42 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator private readonly ICredentialStore _credentialStore; // authentication private readonly ICredentialValidator _credentialValidator; private readonly IUserRuntimeStateProvider _users; // eligible - private readonly IUserSecurityStateProvider _userSecurityStateProvider; // runtime risk private readonly ILoginAuthority _authority; private readonly ISessionOrchestrator _sessionOrchestrator; private readonly ITokenIssuer _tokens; private readonly IUserClaimsProvider _claimsProvider; private readonly IUserIdConverterResolver _userIdConverterResolver; + private readonly IUserSecurityStateWriter _securityWriter; + private readonly IUserSecurityStateProvider _securityStateProvider; // runtime risk + private readonly UAuthEventDispatcher _events; + private readonly UAuthServerOptions _options; public LoginOrchestrator( ICredentialStore credentialStore, ICredentialValidator credentialValidator, IUserRuntimeStateProvider users, - IUserSecurityStateProvider userSecurityStateProvider, ILoginAuthority authority, ISessionOrchestrator sessionOrchestrator, ITokenIssuer tokens, IUserClaimsProvider claimsProvider, - IUserIdConverterResolver userIdConverterResolver) + IUserIdConverterResolver userIdConverterResolver, + IUserSecurityStateWriter securityWriter, + IUserSecurityStateProvider securityStateProvider, + UAuthEventDispatcher events, + IOptions options) { _credentialStore = credentialStore; _credentialValidator = credentialValidator; _users = users; - _userSecurityStateProvider = userSecurityStateProvider; _authority = authority; _sessionOrchestrator = sessionOrchestrator; _tokens = tokens; _claimsProvider = claimsProvider; _userIdConverterResolver = userIdConverterResolver; + _securityWriter = securityWriter; + _securityStateProvider = securityStateProvider; + _events = events; + _options = options.Value; } public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) @@ -50,49 +63,56 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req ct.ThrowIfCancellationRequested(); var now = request.At ?? DateTimeOffset.UtcNow; - var credentials = await _credentialStore.FindByLoginAsync(request.Tenant, request.Identifier, ct); - var orderedCredentials = credentials - .OfType() - .Where(c => c.Security.IsUsable(now)) - .Cast>() - .ToList(); + bool hasCandidateUser = false; + TUserId candidateUserId = default!; TUserId validatedUserId = default!; bool credentialsValid = false; - foreach (var credential in orderedCredentials) + foreach (var credential in credentials.OfType()) { - var result = await _credentialValidator.ValidateAsync(credential, request.Secret, ct); + if (!credential.Security.IsUsable(now)) + continue; + + var typed = (ICredential)credential; + + if (!hasCandidateUser) + { + candidateUserId = typed.UserId; + hasCandidateUser = true; + } + + var result = await _credentialValidator.ValidateAsync((ICredential)credential, request.Secret, ct); if (result.IsValid) { - validatedUserId = credential.UserId; + validatedUserId = ((ICredential)credential).UserId; credentialsValid = true; break; } } - bool userExists = credentialsValid; - + bool userExists = false; IUserSecurityState? securityState = null; UserKey? userKey = null; - if (credentialsValid) + if (candidateUserId is not null) { - securityState = await _userSecurityStateProvider.GetAsync(request.Tenant, validatedUserId, ct); + securityState = await _securityStateProvider.GetAsync(request.Tenant, candidateUserId, ct); var converter = _userIdConverterResolver.GetConverter(); - userKey = UserKey.FromString(converter.ToCanonicalString(validatedUserId)); - } - - var user = userKey is not null - ? await _users.GetAsync(request.Tenant, userKey.Value, ct) - : null; + var canonicalUserId = converter.ToCanonicalString(candidateUserId); - if (user is null || user.IsDeleted || !user.IsActive) - { - // Deliberately vague - return LoginResult.Failed(); + if (!string.IsNullOrWhiteSpace(canonicalUserId)) + { + var tempUserKey = UserKey.FromString(canonicalUserId); + var user = await _users.GetAsync(request.Tenant, tempUserKey, ct); + if (user is not null && user.IsActive && !user.IsDeleted) + { + userKey = tempUserKey; + userExists = true; + } + } } var decisionContext = new LoginDecisionContext @@ -108,6 +128,33 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req var decision = _authority.Decide(decisionContext); + if (candidateUserId is not null) + { + if (decision.Kind == LoginDecisionKind.Allow) + { + await _securityWriter.ResetFailuresAsync(request.Tenant, candidateUserId, ct); + } + else + { + var isCurrentlyLocked = securityState?.IsLocked == true && securityState?.LockedUntil is DateTimeOffset until && until > now; + + if (!isCurrentlyLocked) + { + await _securityWriter.RecordFailedLoginAsync(request.Tenant, candidateUserId, now, ct); + + var currentFailures = securityState?.FailedLoginAttempts ?? 0; + var nextCount = currentFailures + 1; + + if (_options.Login.MaxFailedAttempts > 0 && nextCount >= _options.Login.MaxFailedAttempts) + { + var lockedUntil = now.AddMinutes(_options.Login.LockoutMinutes); + await _securityWriter.LockUntilAsync(request.Tenant, candidateUserId, lockedUntil, ct); + } + } + + } + } + if (decision.Kind == LoginDecisionKind.Deny) return LoginResult.Failed(); @@ -120,10 +167,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req }); } - if (userKey is not UserKey validUserKey) - { + if (validatedUserId is null || userKey is not UserKey validUserKey) return LoginResult.Failed(); - } var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, validUserKey, ct); @@ -135,7 +180,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Device = request.Device, Claims = claims, ChainId = request.ChainId, - Metadata = SessionMetadata.Empty + Metadata = SessionMetadata.Empty, + Mode = flow.EffectiveMode }; var authContext = flow.ToAuthContext(now); @@ -161,6 +207,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req }; } + await _events.DispatchAsync(new UserLoggedInContext(request.Tenant, validUserKey, now, request.Device, issuedSession.Session.SessionId)); + return LoginResult.Success(issuedSession.Session.SessionId, tokens); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs index 3f1fdc72..b9702d77 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs @@ -15,9 +15,8 @@ public PkceAuthorizationArtifact( string codeChallenge, PkceChallengeMethod challengeMethod, DateTimeOffset expiresAt, - int maxAttempts, PkceContextSnapshot context) - : base(AuthArtifactType.PkceAuthorizationCode, expiresAt, maxAttempts) + : base(AuthArtifactType.PkceAuthorizationCode, expiresAt) { AuthorizationCode = authorizationCode; CodeChallenge = codeChallenge; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs index 0265ce7f..1f1f22b2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs @@ -8,23 +8,15 @@ internal sealed class PkceAuthorizationValidator : IPkceAuthorizationValidator { public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string codeVerifier, PkceContextSnapshot completionContext, DateTimeOffset now) { - // 1️⃣ Expiration if (artifact.IsExpired(now)) return PkceValidationResult.Fail(PkceValidationFailureReason.ArtifactExpired); - // 2️⃣ Attempt limit - if (!artifact.CanAttempt()) - return PkceValidationResult.Fail(PkceValidationFailureReason.MaxAttemptsExceeded); - - // 3️⃣ Context consistency //if (!IsContextValid(artifact.Context, completionContext)) //return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch); - // 4️⃣ Challenge method if (artifact.ChallengeMethod != PkceChallengeMethod.S256) return PkceValidationResult.Fail(PkceValidationFailureReason.UnsupportedChallengeMethod); - // 5️⃣ Verifier check if (!IsVerifierValid(codeVerifier, artifact.CodeChallenge)) return PkceValidationResult.Fail(PkceValidationFailureReason.InvalidVerifier); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs deleted file mode 100644 index 54667497..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AuthRedirectResolver.cs +++ /dev/null @@ -1,63 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public sealed class AuthRedirectResolver -{ - private readonly UAuthServerOptions _options; - - public AuthRedirectResolver(IOptions options) - { - _options = options.Value; - } - - // TODO: Add allowed origins validation - public string ResolveClientBase(HttpContext ctx) - { - if (ctx.Request.Query.TryGetValue("returnUrl", out var returnUrl) && - Uri.TryCreate(returnUrl!, UriKind.Absolute, out var ru)) - { - return ru.GetLeftPart(UriPartial.Authority); - } - - if (ctx.Request.Headers.TryGetValue("Origin", out var origin) && - Uri.TryCreate(origin!, UriKind.Absolute, out var originUri)) - { - return originUri.GetLeftPart(UriPartial.Authority); - } - - if (ctx.Request.Headers.TryGetValue("Referer", out var referer) && - Uri.TryCreate(referer!, UriKind.Absolute, out var refUri)) - { - return refUri.GetLeftPart(UriPartial.Authority); - } - - if (!string.IsNullOrWhiteSpace(_options.Hub.ClientBaseAddress)) - return _options.Hub.ClientBaseAddress; - - return $"{ctx.Request.Scheme}://{ctx.Request.Host}"; - } - - public string ResolveRedirect(HttpContext ctx, string path, IDictionary? query = null) - { - var url = Combine(ResolveClientBase(ctx), path); - - 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}"; - } - - private static string Combine(string baseUri, string path) - { - return baseUri.TrimEnd('/') + "/" + path.TrimStart('/'); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs index 440f14a8..13582524 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookieManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Cookies; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IUAuthCookieManager { diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs index 8f445d73..18d3f160 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/IUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs @@ -3,7 +3,7 @@ using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Cookies; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IUAuthCookiePolicyBuilder { diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs similarity index 90% rename from src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs index 0bc8e1fc..42067c60 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookieManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Cookies; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class UAuthCookieManager : IUAuthCookieManager { diff --git a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs index 92523b04..9d75487a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Cookies/UAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs @@ -4,7 +4,7 @@ using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Cookies; +namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class UAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder { @@ -65,8 +65,8 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, return kind switch { CredentialKind.Session => ResolveSessionLifetime(context), - CredentialKind.RefreshToken => context.EffectiveOptions.Options.Tokens.RefreshTokenLifetime, - CredentialKind.AccessToken => context.EffectiveOptions.Options.Tokens.AccessTokenLifetime, + CredentialKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, + CredentialKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, _ => null }; } @@ -74,7 +74,7 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, private static TimeSpan? ResolveSessionLifetime(AuthFlowContext context) { var sessionIdle = context.EffectiveOptions.Options.Session.IdleTimeout; - var refresh = context.EffectiveOptions.Options.Tokens.RefreshTokenLifetime; + var refresh = context.EffectiveOptions.Options.Token.RefreshTokenLifetime; return context.EffectiveMode switch { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs index 71cfcff3..2a23d8b3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs @@ -65,7 +65,7 @@ private static bool TryFromAuthorizationHeader(HttpContext ctx, out TransportCre private static bool TryFromCookies( HttpContext ctx, - UAuthCookieSetOptions cookieSet, + UAuthCookiePolicyOptions cookieSet, out TransportCredential credential) { credential = default!; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index 0334dd82..add44279 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -3,7 +3,6 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs index 975f6c1f..dde39b84 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs @@ -26,7 +26,6 @@ public DevelopmentJwtSigningKeyProvider() public JwtSigningKey Resolve(string? keyId) { - // signing veya verify için tek key return _key; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 709aea0c..4975109f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -15,10 +15,7 @@ public sealed class UAuthSessionIssuer : ISessionIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly UAuthServerOptions _options; - public UAuthSessionIssuer( - ISessionStoreKernelFactory kernelFactory, - IOpaqueTokenGenerator opaqueGenerator, - IOptions options) + public UAuthSessionIssuer(ISessionStoreKernelFactory kernelFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) { _kernelFactory = kernelFactory; _opaqueGenerator = opaqueGenerator; @@ -28,7 +25,7 @@ public UAuthSessionIssuer( public async Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority - if (_options.Mode == UAuthMode.PureJwt) + if (context.Mode == UAuthMode.PureJwt) { throw new InvalidOperationException("Session issuance is not allowed in PureJwt mode."); } @@ -63,7 +60,7 @@ public async Task IssueLoginSessionAsync(AuthenticatedSessionCont { Session = session, OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid }; var kernel = _kernelFactory.Create(context.Tenant); @@ -135,7 +132,7 @@ public async Task RotateSessionAsync(SessionRotationContext conte metadata: context.Metadata ), OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid }; await kernel.ExecuteAsync(async _ => @@ -159,10 +156,10 @@ await kernel.ExecuteAsync(async _ => return issued; } - public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { var kernel = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeSessionAsync(sessionId, at), ct); + return await kernel.ExecuteAsync(_ => kernel.RevokeSessionAsync(sessionId, at), ct); } public async Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index a30b4062..16cd8025 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -19,22 +19,20 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; private readonly IRefreshTokenStore _refreshTokenStore; - private readonly IUserIdConverterResolver _converterResolver; private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore,IUserIdConverterResolver converterResolver, IClock clock) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; _refreshTokenStore = refreshTokenStore; - _converterResolver = converterResolver; _clock = clock; } public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) { - var tokens = flow.OriginalOptions.Tokens; + var tokens = flow.OriginalOptions.Token; var now = _clock.UtcNow; var expires = now.Add(tokens.AccessTokenLifetime); @@ -48,8 +46,7 @@ UAuthMode.SemiHybrid or UAuthMode.PureJwt => Task.FromResult(IssueJwtAccessToken(context, tokens, expires)), - _ => throw new InvalidOperationException( - $"Unsupported auth mode: {flow.EffectiveMode}") + _ => throw new InvalidOperationException($"Unsupported auth mode: {flow.EffectiveMode}") }; } @@ -58,7 +55,7 @@ UAuthMode.SemiHybrid or if (flow.EffectiveMode == UAuthMode.PureOpaque) return null; - var expires = _clock.UtcNow.Add(flow.OriginalOptions.Tokens.RefreshTokenLifetime); + var expires = _clock.UtcNow.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); var raw = _opaqueGenerator.Generate(); var hash = _tokenHasher.Hash(raw); @@ -107,7 +104,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken { var claims = new Dictionary { - ["sub"] = context.UserKey, + ["sub"] = context.UserKey.Value, ["tenant"] = context.Tenant }; @@ -118,7 +115,7 @@ private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthToken claims["sid"] = context.SessionId!; if (tokens.AddJwtIdClaim) - claims["jti"] = _opaqueGenerator.Generate(16); + claims["jti"] = _opaqueGenerator.GenerateJwtId(); var descriptor = new UAuthJwtTokenDescriptor { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs index 5de0cf3d..ba7d89f8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs @@ -1,8 +1,21 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class OpaqueTokenGenerator : IOpaqueTokenGenerator { - public string Generate(int bytes) => Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(bytes)); + 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) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs index 0bd2ee32..a2aa8f20 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs @@ -4,11 +4,10 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed record RevokeSessionCommand(AuthSessionId SessionId) : ISessionCommand +internal sealed record RevokeSessionCommand(AuthSessionId SessionId) : ISessionCommand { - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) { - await issuer.RevokeSessionAsync(context.Tenant, SessionId, context.At, ct); - return Unit.Value; + return await issuer.RevokeSessionAsync(context.Tenant, SessionId, context.At, ct); } } 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..20c98728 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs @@ -0,0 +1,83 @@ +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 AuthRedirectResolver : IAuthRedirectResolver +{ + private readonly ClientBaseAddressResolver _baseAddressResolver; + + public AuthRedirectResolver(ClientBaseAddressResolver baseAddressResolver) + { + _baseAddressResolver = baseAddressResolver; + } + + public RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext ctx) + => Resolve(flow, ctx, flow.Response.Redirect.SuccessPath, null); + + public RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext ctx, AuthFailureReason reason) + => Resolve(flow, ctx, flow.Response.Redirect.FailurePath, reason); + + private RedirectDecision Resolve(AuthFlowContext flow, HttpContext ctx, string? fallbackPath, AuthFailureReason? failureReason) + { + var redirect = flow.Response.Redirect; + + if (!redirect.Enabled) + return RedirectDecision.None(); + + if (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)) + { + var baseAddress = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); + + IDictionary? query = null; + + if (failureReason is not null) + { + var code = redirect.FailureCodes != null && + redirect.FailureCodes.TryGetValue(failureReason.Value, out var mapped) + ? mapped + : "failed"; + + query = new Dictionary + { + [redirect.FailureQueryKey ?? "error"] = code + }; + } + + return RedirectDecision.To(UrlComposer.Combine(baseAddress, fallbackPath, query)); + } + + return RedirectDecision.None(); + } + + private static void ValidateAllowed(string baseAddress, UAuthServerOptions options) + { + if (options.Hub.AllowedClientOrigins.Count == 0) + return; + + if (!options.Hub.AllowedClientOrigins.Any(o => Normalize(o) == Normalize(baseAddress))) + { + throw new InvalidOperationException($"Redirect to '{baseAddress}' is not allowed."); + } + } + + private static string Normalize(string uri) => uri.TrimEnd('/').ToLowerInvariant(); +} 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..55c789ca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs @@ -0,0 +1,11 @@ +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); +} 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/SessionId/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs index c57b633f..daa4862d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class BearerSessionIdResolver : IInnerSessionIdResolver { - public string Key => "bearer"; + public string Name => "bearer"; public AuthSessionId? Resolve(HttpContext context) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs index fcec4400..1a687013 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs @@ -1,22 +1,32 @@ 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 IReadOnlyList _resolvers; + private readonly IReadOnlyDictionary _resolvers; + private readonly UAuthSessionResolutionOptions _options; - public CompositeSessionIdResolver(IEnumerable resolvers) + public CompositeSessionIdResolver(IEnumerable resolvers, IOptions options) { - _resolvers = resolvers.ToList(); + _options = options.Value.SessionResolution; + _resolvers = resolvers.ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase); } public AuthSessionId? Resolve(HttpContext context) { - foreach (var resolver in _resolvers) + 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; @@ -24,4 +34,13 @@ public CompositeSessionIdResolver(IEnumerable resolvers 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 index c1bab7c3..0d7bbaef 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class CookieSessionIdResolver : IInnerSessionIdResolver { - public string Key => "cookie"; + public string Name => "cookie"; private readonly UAuthServerOptions _options; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs index 7c1bca18..7b4fe2ad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class HeaderSessionIdResolver : IInnerSessionIdResolver { - public string Key => "header"; + public string Name => "header"; private readonly UAuthServerOptions _options; public HeaderSessionIdResolver(IOptions options) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs index 5ef9dc1f..f09ea5cc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs @@ -5,6 +5,6 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IInnerSessionIdResolver { - string Key { get; } + string Name { get; } AuthSessionId? Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs index 2082c612..268bd1d3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class QuerySessionIdResolver : IInnerSessionIdResolver { - public string Key => "query"; + public string Name => "query"; private readonly UAuthServerOptions _options; public QuerySessionIdResolver(IOptions options) 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/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index 1f9e4a4d..e34c23d0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -32,12 +32,12 @@ public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOp if (!resolution.IsResolved) { - if (opts.RequireTenant) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Tenant is required."); - return; - } + //if (opts.RequireTenant) + //{ + // context.Response.StatusCode = StatusCodes.Status400BadRequest; + // await context.Response.WriteAsync("Tenant is required."); + // return; + //} context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("Tenant could not be resolved."); diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs index be3e7fc5..1612e00a 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs @@ -12,8 +12,8 @@ public static UAuthTenantContext Create(string? rawTenantId, UAuthMultiTenantOpt if (string.IsNullOrWhiteSpace(rawTenantId)) { - if (options.RequireTenant) - throw new InvalidOperationException("Tenant is required but could not be resolved."); + //if (options.RequireTenant) + // throw new InvalidOperationException("Tenant is required but could not be resolved."); throw new InvalidOperationException("Tenant could not be resolved."); } diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs index 028f16ed..54d80e47 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -18,8 +18,7 @@ public UAuthTenantResolver(ITenantIdResolver idResolver, IOptions ResolveAsync(HttpContext context) { - var resolutionContext = - TenantResolutionContextFactory.FromHttpContext(context); + var resolutionContext =TenantResolutionContextFactory.FromHttpContext(context); var raw = await _idResolver.ResolveTenantIdAsync(resolutionContext); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs index 9419dd2c..a62c5c7b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -33,14 +33,25 @@ public sealed class CredentialResponseOptions }; public CredentialResponseOptions WithCookie(UAuthCookieOptions cookie) - => new() { - Kind = Kind, - Mode = Mode, - Name = Name, - HeaderFormat = HeaderFormat, - TokenFormat = TokenFormat, - Cookie = 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(CredentialKind 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 index 8a8ae0aa..9b8d9f6c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -6,9 +6,9 @@ namespace CodeBeam.UltimateAuth.Server.Options; internal class ConfigureDefaults { - internal static void ApplyModeDefaults(UAuthServerOptions o) + internal static void ApplyModeDefaults(UAuthMode effectiveMode, UAuthServerOptions o) { - switch (o.Mode) + switch (effectiveMode) { case UAuthMode.PureOpaque: ApplyPureOpaqueDefaults(o); @@ -27,14 +27,14 @@ internal static void ApplyModeDefaults(UAuthServerOptions o) break; default: - throw new InvalidOperationException($"Unsupported UAuthMode: {o.Mode}"); + throw new InvalidOperationException($"Unsupported UAuthMode: {effectiveMode}"); } } private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) { var s = o.Session; - var t = o.Tokens; + var t = o.Token; var c = o.Cookie; var r = o.AuthResponse; @@ -72,7 +72,7 @@ private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) private static void ApplyHybridDefaults(UAuthServerOptions o) { var s = o.Session; - var t = o.Tokens; + var t = o.Token; var c = o.Cookie; var r = o.AuthResponse; @@ -97,7 +97,7 @@ private static void ApplyHybridDefaults(UAuthServerOptions o) private static void ApplySemiHybridDefaults(UAuthServerOptions o) { var s = o.Session; - var t = o.Tokens; + var t = o.Token; var p = o.Pkce; var c = o.Cookie; @@ -117,7 +117,7 @@ private static void ApplySemiHybridDefaults(UAuthServerOptions o) private static void ApplyPureJwtDefaults(UAuthServerOptions o) { var s = o.Session; - var t = o.Tokens; + var t = o.Token; var p = o.Pkce; var c = o.Cookie; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs index a2b7f955..0eaf5182 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; using Microsoft.AspNetCore.Http; @@ -9,4 +10,5 @@ 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 index b542d2aa..d9aafb6d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs @@ -6,14 +6,19 @@ public sealed class LoginRedirectOptions { public bool RedirectEnabled { get; set; } = true; - public string SuccessRedirect { get; init; } = "/"; - public string FailureRedirect { get; init; } = "/login"; + public string SuccessRedirect { get; set; } = "/"; + public string FailureRedirect { get; set; } = "/login"; - public string FailureQueryKey { get; init; } = "error"; + 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; + internal LoginRedirectOptions Clone() => new() { RedirectEnabled = RedirectEnabled, @@ -21,6 +26,7 @@ public sealed class LoginRedirectOptions FailureRedirect = FailureRedirect, FailureQueryKey = FailureQueryKey, CodeQueryKey = CodeQueryKey, - FailureCodes = new Dictionary(FailureCodes) + FailureCodes = new Dictionary(FailureCodes), + AllowReturnUrlOverride = AllowReturnUrlOverride }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs similarity index 52% rename from src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs index 56755f2b..d278ec00 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieSetOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs @@ -1,39 +1,29 @@ -using Microsoft.AspNetCore.Http; +namespace CodeBeam.UltimateAuth.Server.Options; -namespace CodeBeam.UltimateAuth.Server.Options; - -public sealed class UAuthCookieSetOptions +public sealed class UAuthCookiePolicyOptions { - public bool EnableSessionCookie { get; set; } = true; - public bool EnableAccessTokenCookie { get; set; } = true; - public bool EnableRefreshTokenCookie { get; set; } = true; - public UAuthCookieOptions Session { get; init; } = new() { Name = "uas", HttpOnly = true, - SameSite = SameSiteMode.None }; public UAuthCookieOptions RefreshToken { get; init; } = new() { Name = "uar", HttpOnly = true, - SameSite = SameSiteMode.None }; public UAuthCookieOptions AccessToken { get; init; } = new() { Name = "uat", HttpOnly = true, - SameSite = SameSiteMode.None }; - internal UAuthCookieSetOptions Clone() => new() + 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 index d8c3023c..af530528 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs @@ -12,5 +12,4 @@ public sealed class UAuthDiagnosticsOptions { EnableRefreshHeaders = EnableRefreshHeaders }; - } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs index 432dc91e..2d904064 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs @@ -21,5 +21,4 @@ public sealed class UAuthHubServerOptions FlowLifetime = FlowLifetime, LoginPath = LoginPath }; - } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs similarity index 82% rename from src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs index dd143c9e..1da6d92d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/PrimaryCredentialPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class PrimaryCredentialPolicy +public sealed class UAuthPrimaryCredentialPolicy { /// /// Default primary credential for UI-style requests. @@ -14,7 +14,7 @@ public sealed class PrimaryCredentialPolicy /// public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; - internal PrimaryCredentialPolicy Clone() => new() + internal UAuthPrimaryCredentialPolicy Clone() => new() { Ui = Ui, Api = Api diff --git a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs similarity index 87% rename from src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs index 0c9dda0e..9297b557 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/AuthResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class AuthResponseOptions +public sealed class UAuthResponseOptions { public CredentialResponseOptions SessionIdDelivery { get; set; } = new(); public CredentialResponseOptions AccessTokenDelivery { get; set; } = new(); @@ -9,7 +9,7 @@ public sealed class AuthResponseOptions public LoginRedirectOptions Login { get; set; } = new(); public LogoutRedirectOptions Logout { get; set; } = new(); - internal AuthResponseOptions Clone() => new() + internal UAuthResponseOptions Clone() => new() { SessionIdDelivery = SessionIdDelivery.Clone(), AccessTokenDelivery = AccessTokenDelivery.Clone(), @@ -17,5 +17,4 @@ public sealed class AuthResponseOptions 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..b586f272 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs @@ -0,0 +1,37 @@ +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 Login { 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; + + internal UAuthServerEndpointOptions Clone() => new() + { + Login = Login, + Pkce = Pkce, + Token = Token, + Session = Session, + //UserInfo = UserInfo, + UserLifecycle = UserLifecycle, + UserProfile = UserProfile, + UserIdentifier = UserIdentifier, + Credentials = Credentials, + Authorization = Authorization + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index b80b1c6a..c8e03cb7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -1,8 +1,7 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Cookies; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Routing; namespace CodeBeam.UltimateAuth.Server.Options; @@ -14,81 +13,66 @@ namespace CodeBeam.UltimateAuth.Server.Options; /// public sealed class UAuthServerOptions { - /// - /// Defines how UltimateAuth executes authentication flows. - /// Default is Hybrid. - /// - public UAuthMode? Mode { 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; - - // ------------------------------------------------------- - // ROUTING - // ------------------------------------------------------- - - /// - /// Base API route. Default: "/auth" - /// Changing this prevents conflicts with other auth systems. - /// - public string RoutePrefix { get; set; } = "/auth"; - // ------------------------------------------------------- // 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. + /// 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. + /// Token issuing behavior (lifetimes, refresh policies). Fully defined in Core. /// - public UAuthTokenOptions Tokens { get; set; } = new(); + public UAuthTokenOptions Token { get; set; } = new(); /// - /// PKCE configuration (required for WASM). - /// Fully defined in Core. + /// 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. + /// 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 UAuthCookieSetOptions Cookie { get; set; } = new(); + public UAuthCookiePolicyOptions Cookie { get; set; } = new(); public UAuthDiagnosticsOptions Diagnostics { get; set; } = new(); - internal Type? CustomCookieManagerType { get; private set; } - - public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManager - { - CustomCookieManagerType = typeof(T); - } - - // ------------------------------------------------------- - // SERVER-ONLY BEHAVIOR - // ------------------------------------------------------- - - public PrimaryCredentialPolicy PrimaryCredential { get; init; } = new(); + public UAuthPrimaryCredentialPolicy PrimaryCredential { get; init; } = new(); - public AuthResponseOptions AuthResponse { get; init; } = new(); + public UAuthResponseOptions AuthResponse { get; init; } = new(); public UAuthHubServerOptions Hub { get; set; } = new(); @@ -99,33 +83,22 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public UAuthSessionResolutionOptions SessionResolution { get; set; } = new(); /// - /// Enables/disables specific endpoint groups. - /// Useful for API hardening. + /// Enables/disables specific endpoint groups. Useful for API hardening. /// - public bool? EnableLoginEndpoints { get; set; } = true; - public bool? EnablePkceEndpoints { get; set; } = true; - public bool? EnableTokenEndpoints { get; set; } = true; - public bool? EnableSessionEndpoints { get; set; } = true; - public bool? EnableUserInfoEndpoints { get; set; } = true; + public UAuthServerEndpointOptions Endpoints { get; set; } = new(); - public bool? EnableUserLifecycleEndpoints { get; set; } = true; - public bool? EnableUserProfileEndpoints { get; set; } = true; - public bool? EnableUserIdentifierEndpoints { get; set; } = true; - public bool? EnableCredentialsEndpoints { get; set; } = true; - public bool? EnableAuthorizationEndpoints { get; set; } = true; + public UAuthUserIdentifierOptions UserIdentifiers { get; set; } = new(); - public UserIdentifierOptions UserIdentifiers { get; set; } = new(); - - /// - /// If true, server will add anti-forgery headers - /// and require proper request metadata. - /// - public bool EnableAntiCsrfProtection { get; set; } = true; + ///// + ///// 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; + ///// + ///// If true, login attempts are rate-limited to prevent brute force attacks. + ///// + //public bool EnableLoginRateLimiting { get; set; } = true; // ------------------------------------------------------- @@ -133,17 +106,11 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage // ------------------------------------------------------- /// - /// Allows developers to mutate endpoint routing AFTER UltimateAuth registers defaults. - /// Example: adding new routes, overriding authorization, adding filters. - /// - public Action? OnConfigureEndpoints { get; set; } - - /// - /// Allows developers to add or replace server services before DI is built. - /// Example: overriding default ILoginService. + /// 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? ConfigureServices { get; set; } - + public Action? OnConfigureEndpoints { get; set; } internal Dictionary> ModeConfigurations { get; set; } = new(); @@ -152,13 +119,14 @@ internal UAuthServerOptions Clone() { return new UAuthServerOptions { - Mode = Mode, + AllowedModes = AllowedModes?.ToArray(), HubDeploymentMode = HubDeploymentMode, - RoutePrefix = RoutePrefix, + Login = Login.Clone(), Session = Session.Clone(), - Tokens = Tokens.Clone(), + Token = Token.Clone(), Pkce = Pkce.Clone(), + Events = Events.Clone(), MultiTenant = MultiTenant.Clone(), Cookie = Cookie.Clone(), Diagnostics = Diagnostics.Clone(), @@ -168,24 +136,14 @@ internal UAuthServerOptions Clone() Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), UserIdentifiers = UserIdentifiers.Clone(), + Endpoints = Endpoints.Clone(), - EnableLoginEndpoints = EnableLoginEndpoints, - EnablePkceEndpoints = EnablePkceEndpoints, - EnableTokenEndpoints = EnableTokenEndpoints, - EnableSessionEndpoints = EnableSessionEndpoints, - EnableUserInfoEndpoints = EnableUserInfoEndpoints, - EnableUserLifecycleEndpoints = EnableUserLifecycleEndpoints, - EnableUserProfileEndpoints = EnableUserProfileEndpoints, - EnableCredentialsEndpoints = EnableCredentialsEndpoints, - EnableAuthorizationEndpoints = EnableAuthorizationEndpoints, + //EnableAntiCsrfProtection = EnableAntiCsrfProtection, + //EnableLoginRateLimiting = EnableLoginRateLimiting, - EnableAntiCsrfProtection = EnableAntiCsrfProtection, - EnableLoginRateLimiting = EnableLoginRateLimiting, + ModeConfigurations = new Dictionary>(ModeConfigurations), - ModeConfigurations = ModeConfigurations, OnConfigureEndpoints = OnConfigureEndpoints, - ConfigureServices = ConfigureServices, - CustomCookieManagerType = CustomCookieManagerType }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs deleted file mode 100644 index 1bc7c36f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptionsValidator.cs +++ /dev/null @@ -1,42 +0,0 @@ -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.RoutePrefix)) - { - return ValidateOptionsResult.Fail( "RoutePrefix must be specified."); - } - - if (options.RoutePrefix.Contains("//")) - { - return ValidateOptionsResult.Fail("RoutePrefix cannot contain '//'."); - } - - if (options.Mode.HasValue && !Enum.IsDefined(typeof(UAuthMode), options.Mode)) - { - return ValidateOptionsResult.Fail($"Invalid UAuthMode: {options.Mode}"); - } - - if (options.Mode != UAuthMode.PureJwt) - { - 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."); - } - } - - return ValidateOptionsResult.Success; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs index c7876148..f36b16bd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs @@ -7,7 +7,7 @@ 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; } = false; + public bool EnableQuery { get; set; } = true; public string HeaderName { get; set; } = "X-UAuth-Session"; public string QueryParameterName { get; set; } = "session_id"; @@ -32,5 +32,4 @@ public sealed class UAuthSessionResolutionOptions QueryParameterName = QueryParameterName, Order = new List(Order) }; - } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs similarity index 90% rename from src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs index e7e14434..0429af6f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class UserIdentifierOptions +public sealed class UAuthUserIdentifierOptions { public bool AllowUsernameChange { get; set; } = true; public bool AllowMultipleUsernames { get; set; } = false; @@ -13,7 +13,7 @@ public sealed class UserIdentifierOptions public bool AllowAdminOverride { get; set; } = true; public bool AllowUserOverride { get; set; } = true; - internal UserIdentifierOptions Clone() => new() + internal UAuthUserIdentifierOptions Clone() => new() { AllowUsernameChange = AllowUsernameChange, AllowMultipleUsernames = AllowMultipleUsernames, 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..450b7bbd --- /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.LockoutMinutes < 0) + 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..82af7684 --- /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.UserIdentifiers.AllowAdminOverride && !options.UserIdentifiers.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/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/ProductInfo/UAuthServerProductInfo.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/ProductInfo/UAuthServerProductInfo.cs rename to src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 18202efc..aebf9590 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -1,27 +1,34 @@ 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.Infrastructure; using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Server.Services; internal sealed class UAuthFlowService : IUAuthFlowService { private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAuthFlowContextFactory _authFlowContextFactory; private readonly ILoginOrchestrator _loginOrchestrator; private readonly ISessionOrchestrator _orchestrator; + private readonly UAuthEventDispatcher _events; public UAuthFlowService( IAuthFlowContextAccessor authFlow, + IAuthFlowContextFactory authFlowContextFactory, ILoginOrchestrator loginOrchestrator, - ISessionOrchestrator orchestrator) + ISessionOrchestrator orchestrator, + UAuthEventDispatcher events) { _authFlow = authFlow; + _authFlowContextFactory = authFlowContextFactory; _loginOrchestrator = loginOrchestrator; _orchestrator = orchestrator; + _events = events; } public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) @@ -44,21 +51,29 @@ public Task LoginAsync(AuthFlowContext flow, LoginRequest request, return _loginOrchestrator.LoginAsync(flow, request, ct); } - public Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) + public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) { var effectiveFlow = execution.EffectiveClientProfile is null ? flow - : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); - return _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); + : await _authFlowContextFactory.RecreateWithClientProfileAsync(flow, (UAuthClientProfile)execution.EffectiveClientProfile, ct); + return await _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); } - public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) { var authFlow = _authFlow.Current; var now = request.At ?? DateTimeOffset.UtcNow; var authContext = authFlow.ToAuthContext(now); - return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.SessionId), ct); + 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(request.Tenant, uaKey, request.At ?? DateTimeOffset.Now, LogoutReason.Explicit, request.SessionId)); } public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs index eb7616ba..bf34a06f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs @@ -48,6 +48,38 @@ await strategy.ExecuteAsync(async () => }); } + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + public async Task GetSessionAsync(AuthSessionId sessionId) { var projection = await _db.Sessions @@ -70,20 +102,21 @@ public async Task SaveSessionAsync(UAuthSession session) _db.Sessions.Add(projection); } - public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) { - var projection = await _db.Sessions - .SingleOrDefaultAsync(x => x.SessionId == sessionId); + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); if (projection is null) - return; + return false; var session = projection.ToDomain(); if (session.IsRevoked) - return; + return false; var revoked = session.Revoke(at); _db.Sessions.Update(revoked.ToProjection()); + + return true; } public async Task GetChainAsync(SessionChainId chainId) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs index 0b3cbb16..893a73b0 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs @@ -26,6 +26,19 @@ public async Task ExecuteAsync(Func action, Cancellatio } } + 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) => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); public Task SaveSessionAsync(UAuthSession session) @@ -34,13 +47,16 @@ public Task SaveSessionAsync(UAuthSession session) return Task.CompletedTask; } - public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) { - if (_sessions.TryGetValue(sessionId, out var session)) - { - _sessions[sessionId] = session.Revoke(at); - } - return Task.CompletedTask; + if (!_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(false); + + if (session.IsRevoked) + return Task.FromResult(false); + + _sessions[sessionId] = session.Revoke(at); + return Task.FromResult(true); } public Task GetChainAsync(SessionChainId chainId) 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/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index e6209864..d86b3f6a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -15,6 +15,9 @@ public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceColle services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(typeof(InMemoryUserSecurityStore<>)); + services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); + services.TryAddScoped(typeof(IUserSecurityStateWriter<>), typeof(InMemoryUserSecurityStateWriter<>)); services.TryAddSingleton, InMemoryUserIdProvider>(); // Seed never try add diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs new file mode 100644 index 00000000..ab3eccf0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSecurityState : IUserSecurityState +{ + public long SecurityVersion { get; init; } + public int FailedLoginAttempts { get; init; } + public DateTimeOffset? LockedUntil { get; init; } + public bool RequiresReauthentication { get; init; } + + public bool IsLocked => LockedUntil.HasValue && LockedUntil.Value > DateTimeOffset.UtcNow; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs index 55077d7b..53d47848 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs @@ -2,11 +2,17 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; -internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider +internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider where TUserId : notnull { + private readonly InMemoryUserSecurityStore _store; + + public InMemoryUserSecurityStateProvider(InMemoryUserSecurityStore store) + { + _store = store; + } + public Task GetAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) { - // InMemory default: no MFA, no lockout, no risk signals - return Task.FromResult(null); + return Task.FromResult(_store.Get(tenant, userId)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs new file mode 100644 index 00000000..7f2fc5ed --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSecurityStateWriter : IUserSecurityStateWriter where TUserId : notnull +{ + private readonly InMemoryUserSecurityStore _store; + + public InMemoryUserSecurityStateWriter(InMemoryUserSecurityStore store) + { + _store = store; + } + + public Task RecordFailedLoginAsync(TenantKey tenant, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + { + var current = _store.Get(tenant, userId); + + var next = new InMemoryUserSecurityState + { + SecurityVersion = (current?.SecurityVersion ?? 0) + 1, + FailedLoginAttempts = (current?.FailedLoginAttempts ?? 0) + 1, + LockedUntil = current?.LockedUntil, + RequiresReauthentication = current?.RequiresReauthentication ?? false + }; + + _store.Set(tenant, userId, next); + return Task.CompletedTask; + } + + public Task LockUntilAsync(TenantKey tenant, TUserId userId, DateTimeOffset lockedUntil, CancellationToken ct = default) + { + var current = _store.Get(tenant, userId); + + var next = new InMemoryUserSecurityState + { + SecurityVersion = (current?.SecurityVersion ?? 0) + 1, + FailedLoginAttempts = current?.FailedLoginAttempts ?? 0, + LockedUntil = lockedUntil, + RequiresReauthentication = current?.RequiresReauthentication ?? false + }; + + _store.Set(tenant, userId, next); + return Task.CompletedTask; + } + + public Task ResetFailuresAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) + { + _store.Clear(tenant, userId); + return Task.CompletedTask; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs new file mode 100644 index 00000000..429de15f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSecurityStore : IUserSecurityStateDebugView where TUserId : notnull +{ + private readonly ConcurrentDictionary<(TenantKey, TUserId), InMemoryUserSecurityState> _states = new(); + + public InMemoryUserSecurityState? Get(TenantKey tenant, TUserId userId) + => _states.TryGetValue((tenant, userId), out var state) ? state : null; + + public void Set(TenantKey tenant, TUserId userId, InMemoryUserSecurityState state) + => _states[(tenant, userId)] = state; + + public void Clear(TenantKey tenant, TUserId userId) + => _states.TryRemove((tenant, userId), out _); + + public IUserSecurityState? GetState(TenantKey tenant, TUserId userId) + => Get(tenant, userId); +} 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/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 80d6bbc3..abc82331 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -14,7 +14,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl // Marker only – runtime validation happens via DI resolution }); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 179ed6f2..458344b6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -3,8 +3,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users.Abstractions; using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -15,6 +17,7 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserProfileStore _profileStore; private readonly IUserIdentifierStore _identifierStore; private readonly IEnumerable _integrations; + private readonly UAuthUserIdentifierOptions _identifierOptions; private readonly IClock _clock; public UserApplicationService( @@ -23,6 +26,7 @@ public UserApplicationService( IUserProfileStore profileStore, IUserIdentifierStore identifierStore, IEnumerable integrations, + IOptions options, IClock clock) { _accessOrchestrator = accessOrchestrator; @@ -30,6 +34,7 @@ public UserApplicationService( _profileStore = profileStore; _identifierStore = identifierStore; _integrations = integrations; + _identifierOptions = options.Value.UserIdentifiers; _clock = clock; } @@ -213,6 +218,17 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie { var userKey = context.GetTargetUserKey(); + var existing = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); + EnsureOverrideAllowed(context); + EnsureMultipleIdentifierAllowed(request.Type, existing); + + 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.CreateAsync(context.ResourceTenant, new UserIdentifier { @@ -236,6 +252,13 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (string.Equals(request.OldValue, request.NewValue, StringComparison.Ordinal)) throw new InvalidOperationException("identifier_value_unchanged"); + EnsureOverrideAllowed(context); + + if (request.Type == UserIdentifierType.Username && !_identifierOptions.AllowUsernameChange) + { + throw new InvalidOperationException("username_change_not_allowed"); + } + await _identifierStore.UpdateValueAsync(context.ResourceTenant, request.Type, request.OldValue, request.NewValue, _clock.UtcNow, innerCt); }); @@ -248,6 +271,15 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar { var userKey = context.GetTargetUserKey(); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); + var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); + + if (target is null) + throw new InvalidOperationException("identifier_not_found"); + + EnsureOverrideAllowed(context); + EnsureVerificationRequirements(target.Type, target.IsVerified); + await _identifierStore.SetPrimaryAsync(context.ResourceTenant, userKey, request.Type, request.Value, innerCt); }); @@ -260,6 +292,8 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr { var userKey = context.GetTargetUserKey(); + EnsureOverrideAllowed(context); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); @@ -295,6 +329,8 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde { var targetUserKey = context.GetTargetUserKey(); + EnsureOverrideAllowed(context); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, targetUserKey, innerCt); var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); @@ -359,6 +395,45 @@ private async Task BuildUserViewAsync(TenantKey tenant, UserKey use }; } + private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyList existing) + { + bool hasSameType = existing.Any(i => !i.IsDeleted && i.Type == type); + + if (!hasSameType) + return; + + if (type == UserIdentifierType.Username && !_identifierOptions.AllowMultipleUsernames) + throw new InvalidOperationException("multiple_usernames_not_allowed"); + + if (type == UserIdentifierType.Email && !_identifierOptions.AllowMultipleEmail) + throw new InvalidOperationException("multiple_emails_not_allowed"); + + if (type == UserIdentifierType.Phone && !_identifierOptions.AllowMultiplePhone) + throw new InvalidOperationException("multiple_phones_not_allowed"); + } + + private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) + { + if (type == UserIdentifierType.Email && _identifierOptions.RequireEmailVerification && !isVerified) + { + throw new InvalidOperationException("email_verification_required"); + } + + if (type == UserIdentifierType.Phone && _identifierOptions.RequirePhoneVerification && !isVerified) + { + throw new InvalidOperationException("phone_verification_required"); + } + } + + private void EnsureOverrideAllowed(AccessContext context) + { + if (context.IsSelfAction && !_identifierOptions.AllowUserOverride) + throw new InvalidOperationException("user_override_not_allowed"); + + if (!context.IsSelfAction && !_identifierOptions.AllowAdminOverride) + throw new InvalidOperationException("admin_override_not_allowed"); + } + private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) => (from, to) switch { diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs similarity index 85% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs rename to src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs index e77f70c9..b864f6e8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs @@ -5,11 +5,11 @@ namespace CodeBeam.UltimateAuth.Users.Reference; -internal sealed class UserRuntimeStore : IUserRuntimeStateProvider +internal sealed class UserRuntimeStateProvider : IUserRuntimeStateProvider { private readonly IUserLifecycleStore _lifecycleStore; - public UserRuntimeStore(IUserLifecycleStore lifecycleStore) + public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) { _lifecycleStore = lifecycleStore; } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs index f6b6547d..571cc0f2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs @@ -3,6 +3,9 @@ public interface IUserSecurityState { long SecurityVersion { get; } - bool IsLocked { get; } + int FailedLoginAttempts { get; } + DateTimeOffset? LockedUntil { get; } bool RequiresReauthentication { get; } + + bool IsLocked => LockedUntil.HasValue && LockedUntil > DateTimeOffset.UtcNow; } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs new file mode 100644 index 00000000..bcb09246 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users; + +internal interface IUserSecurityStateDebugView +{ + IUserSecurityState? GetState(TenantKey tenant, TUserId userId); + void Clear(TenantKey tenant, TUserId userId); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs new file mode 100644 index 00000000..103adaba --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityStateWriter +{ + Task RecordFailedLoginAsync(TenantKey tenant, TUserId userId, DateTimeOffset at, CancellationToken ct = default); + Task ResetFailuresAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); + Task LockUntilAsync(TenantKey tenant, TUserId userId, DateTimeOffset lockedUntil, CancellationToken ct = default); +} 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/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs new file mode 100644 index 00000000..cb09ff7b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs @@ -0,0 +1,65 @@ +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); + } +} 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..0da6e73b --- /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, + LockoutMinutes = 0 + }; + + 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, + LockoutMinutes = 0 + }; + + 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 index bf98b03d..952588b5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -4,6 +4,7 @@ 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; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index 17c16dbb..d5518a18 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -45,6 +45,11 @@ public Task LoginAsync(LoginRequest request) throw new NotImplementedException(); } + public Task LoginAsync(LoginRequest request, string? returnUrl) + { + throw new NotImplementedException(); + } + public Task LogoutAsync() { throw new NotImplementedException(); 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..67a4b94d --- /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(CredentialKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.AccessToken), + refreshTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.RefreshToken), + redirect: redirect ?? EffectiveRedirectResponse.Disabled + ), + primaryTokenKind: PrimaryTokenKind.Session, + returnUrlInfo: returnUrlInfo ?? ReturnUrlInfo.None() + ); + } +} 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..137f63b9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +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, + resource: "test", + targetUserKey: null, + resourceTenant: TenantKey.Single, + 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..9cbcab5c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -0,0 +1,68 @@ +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +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.InMemory.Extensions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Sessions.InMemory; +using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users.InMemory.Extensions; +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 TestAuthRuntime(Action? configureServer = null, Action? configureCore = null) + { + var services = new ServiceCollection(); + + services.AddLogging(); + + services.AddUltimateAuth(configureCore ?? (_ => { })); + services.AddUltimateAuthServer(options => + { + configureServer?.Invoke(options); + }); + + services.AddSingleton(); + // InMemory plugins + services.AddUltimateAuthUsersInMemory(); + services.AddUltimateAuthCredentialsInMemory(); + services.AddUltimateAuthInMemorySessions(); + services.AddUltimateAuthInMemoryTokens(); + services.AddUltimateAuthAuthorizationInMemory(); + services.AddUltimateAuthAuthorizationReference(); + + services.AddScoped, LoginOrchestrator>(); + services.AddScoped(); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + services.AddSingleton(configuration); + + + 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); + } +} 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/TestDevice.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs new file mode 100644 index 00000000..7265c154 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestDevice +{ + public static DeviceContext Default() => DeviceContext.FromDeviceId(DeviceId.Create("test-device-000-000-000-000-01")); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs similarity index 59% rename from tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs rename to tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs index bcfe4c34..38fdf1c1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestHelpers.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs @@ -1,8 +1,12 @@ -using CodeBeam.UltimateAuth.Server.Auth; +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; +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal static class TestHelpers { 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..e9c83b01 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Middlewares; +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[TenantMiddleware.TenantContextKey] = UAuthTenantContext.Resolved(resolvedTenant); + + 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/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs similarity index 85% rename from tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs rename to tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs index 4aa8dbc8..fca5e3a5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/TestIds.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Tests.Unit; +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal static class TestIds { 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/TestRedirectResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs new file mode 100644 index 00000000..ce721ead --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs @@ -0,0 +1,38 @@ +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) + => _inner.ResolveFailure(flow, ctx, reason); + + 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/Policies/ActionTextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs index 3c1c08f5..bb7e05d6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -11,23 +11,16 @@ public class ActionTextTests [InlineData("users.profile.get", false)] public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) { - var context = new AccessContext { Action = action }; + var context = TestAccessContext.WithAction(action); var policy = new RequireAdminPolicy(); - Assert.Equal(expected, policy.AppliesTo(context)); } [Fact] public void RequireAdminPolicy_DoesNotMatch_Substrings() { - var context = new AccessContext - { - Action = "users.profile.get.administrator" - }; - + var context = TestAccessContext.WithAction("users.profile.get.administrator"); var policy = new RequireAdminPolicy(); - Assert.False(policy.AppliesTo(context)); } - } 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 index 9c27d724..9b72cccf 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs @@ -9,17 +9,6 @@ public class EffectiveAuthModeResolverTests { private readonly EffectiveAuthModeResolver _resolver = new(); - [Fact] - public void ConfiguredMode_Wins_Over_ClientProfile() - { - var mode = _resolver.Resolve( - configuredMode: UAuthMode.PureJwt, - clientProfile: UAuthClientProfile.BlazorWasm, - flowType: AuthFlowType.Login); - - Assert.Equal(UAuthMode.PureJwt, mode); - } - [Theory] [InlineData(UAuthClientProfile.BlazorServer, UAuthMode.PureOpaque)] [InlineData(UAuthClientProfile.BlazorWasm, UAuthMode.Hybrid)] @@ -27,12 +16,7 @@ public void ConfiguredMode_Wins_Over_ClientProfile() [InlineData(UAuthClientProfile.Api, UAuthMode.PureJwt)] public void Default_Mode_Is_Derived_From_ClientProfile(UAuthClientProfile profile, UAuthMode expected) { - var mode = _resolver.Resolve( - configuredMode: null, - clientProfile: profile, - flowType: AuthFlowType.Login); - + 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 index 17df3e17..f2d98b20 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs @@ -1,9 +1,15 @@ 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 Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -12,104 +18,66 @@ public class EffectiveServerOptionsProviderTests [Fact] public void Original_Options_Are_Not_Mutated() { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; + var baseOptions = new UAuthServerOptions(); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); - - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); + var ctx = TestHttpContext.Create(); - effective.Options.Tokens.AccessTokenLifetime = TimeSpan.FromSeconds(10); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + effective.Options.Token.AccessTokenLifetime = TimeSpan.FromSeconds(10); - Assert.NotEqual( - baseOptions.Tokens.AccessTokenLifetime, - effective.Options.Tokens.AccessTokenLifetime - ); + Assert.NotEqual(baseOptions.Token.AccessTokenLifetime, effective.Options.Token.AccessTokenLifetime); } - [Fact] - public void EffectiveMode_Comes_From_ModeResolver() + public void EffectiveMode_Is_Determined_By_ModeResolver() { - var baseOptions = new UAuthServerOptions - { - Mode = null // Not specified - }; + var baseOptions = new UAuthServerOptions(); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); - - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.Api); + 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() + public void Mode_Defaults_Are_Applied_Before_Overrides() { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.PureOpaque - }; + var baseOptions = new UAuthServerOptions(); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); - - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); + 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_Defaults() + public void ModeConfiguration_Overrides_Mode_Defaults() { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; + var baseOptions = new UAuthServerOptions(); - baseOptions.ConfigureMode(UAuthMode.Hybrid, o => + baseOptions.ConfigureMode(UAuthMode.PureOpaque, o => { - o.Tokens.AccessTokenLifetime = TimeSpan.FromMinutes(1); + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(1); }); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); + var ctx = TestHttpContext.Create(); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); - var effective = provider.GetEffective( - ctx, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); - - Assert.Equal( - TimeSpan.FromMinutes(1), - effective.Options.Tokens.AccessTokenLifetime - ); + Assert.Equal(TimeSpan.FromMinutes(1), effective.Options.Token.AccessTokenLifetime); } [Fact] public void Each_Call_Returns_New_EffectiveOptions_Instance() { - var baseOptions = new UAuthServerOptions - { - Mode = UAuthMode.Hybrid - }; + var baseOptions = new UAuthServerOptions(); var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); - var ctx = new DefaultHttpContext(); + var ctx = TestHttpContext.Create(); var first = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); var second = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); 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..b237d8fb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -0,0 +1,420 @@ +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.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.InMemory; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using System.Security; + +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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default(), + }); + + 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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default(), + }); + + 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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + + var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + state!.FailedLoginAttempts.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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", // valid password + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + + var state = store.GetState(TenantKey.Single,UserKey.Parse("user", null)); + state.Should().BeNull(); + } + + [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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + 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 + { + Tenant = TenantKey.Single, + Identifier = "ghost", + Secret = "whatever", + Device = TestDevice.Default(), + }); + + 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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + state!.IsLocked.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(); + + // lock + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + // try again with correct password + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default(), + }); + + 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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + var state1 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var state2 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + state2!.FailedLoginAttempts.Should().Be(state1!.FailedLoginAttempts); + } + + [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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + } + + var store = runtime.Services.GetRequiredService>(); + var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + state!.IsLocked.Should().BeFalse(); + state.FailedLoginAttempts.Should().Be(5); + } + + [Fact] + public async Task Invalid_device_id_should_throw_security_exception() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + Func act = () => orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = DeviceContext.FromDeviceId(DeviceId.Create("x")), // too short + }); + + await act.Should().ThrowAsync(); + } + + [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.LockoutMinutes = 15; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var store = runtime.Services.GetRequiredService>(); + var state1 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + + var lockedUntil = state1!.LockedUntil; + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "wrong", + Device = TestDevice.Default(), + }); + + var state2 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + state2!.LockedUntil.Should().Be(lockedUntil); + } + + [Fact] + public async Task Login_success_should_trigger_UserLoggedIn_event() + { + // arrange + 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(); + + // act + await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default() + }); + + // assert + captured.Should().NotBeNull(); + captured!.UserKey.Should().Be(UserKey.Parse("user", null)); + } + + [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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default() + }); + + 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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user", + Device = TestDevice.Default() + }); + + result.IsSuccess.Should().BeTrue(); + } +} 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..58bd4dcd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -0,0 +1,199 @@ +using CodeBeam.UltimateAuth.Core; +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.Extensions.DependencyInjection; + +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 void ClientProfile_Is_Read_From_Header() + { + var reader = new ClientProfileReader(); + var ctx = TestHttpContext.Create(); + ctx.Request.Headers["X-UAuth-ClientProfile"] = "BlazorServer"; + + var profile = reader.Read(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, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: true + ) + ); + + 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, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: 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, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: true + ) + ); + + 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, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: true + ) + ); + + 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, + failureQueryKey: null, + failureCodes: null, + allowReturnUrlOverride: true + ) + ); + + 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_Mapped_Error_Code() + { + var redirect = new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: "/login", + failureQueryKey: "error", + failureCodes: new Dictionary + { + [AuthFailureReason.InvalidCredentials] = "bad_credentials" + }, + allowReturnUrlOverride: false + ); + + 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.TargetUrl.Should().Be("https://app.example.com/login?error=bad_credentials"); + } +} 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..f25dfb04 --- /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.UserIdentifiers.AllowAdminOverride = false; + o.UserIdentifiers.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.UserIdentifiers.AllowAdminOverride = true; + o.UserIdentifiers.AllowUserOverride = false; + }); + + services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + options.UserIdentifiers.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(); + } +} From 211574fe4a7e8401e66f558fd800f0dce1a054cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:09:13 +0300 Subject: [PATCH 31/50] Client Improvization (#19) * Client Improvization * Little Cleanup & UseUltimateAuthWithAspNetCore Pipeline Method Addition * Improvized Sample Page Seperation & Fix UAuthLoginForm ReturnUrl Behavior * Login Redirect Improvization * Improved UAuthStateManager * Complete AuthStateSnapshot * Complete UAuthState * Add Tests & Fix Current Tests * Minify uauth.js * Last Client Refinement & ProductInfo Improvement --- UltimateAuth.slnx | 1 + ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 6 +- .../Components/App.razor | 2 +- .../Components/Pages/Home.razor | 4 +- .../Components/Routes.razor | 4 +- .../Program.cs | 17 +- ...am.UltimateAuth.Sample.BlazorServer.csproj | 10 +- .../Components/App.razor | 2 +- .../Components/Layout/MainLayout.razor | 7 - .../Components/Layout/MainLayout.razor.cs | 12 - .../Components/Pages/Home.razor | 208 +++++++++--------- .../Components/Pages/Home.razor.cs | 132 ++--------- .../Components/Pages/LandingPage.razor | 4 + .../Components/Pages/LandingPage.razor.cs | 17 ++ .../Components/Pages/Login.razor | 61 +++++ .../Components/Pages/Login.razor.cs | 137 ++++++++++++ .../Components/Routes.razor | 20 +- .../Components/_Imports.razor | 3 + .../Program.cs | 68 +----- .../App.razor | 13 +- ...ateAuth.Sample.BlazorStandaloneWasm.csproj | 6 +- .../Pages/Home.razor | 68 +++--- .../Pages/Home.razor.cs | 2 - .../Program.cs | 18 +- .../wwwroot/index.html | 2 +- ...Beam.UltimateAuth.Client.JsMinifier.csproj | 14 ++ .../Program.cs | 35 +++ .../Authentication/IUAuthStateManager.cs | 4 +- .../UAuthAuthenticatonStateProvider.cs | 3 +- .../UAuthCascadingStateProvider.cs | 5 +- .../Authentication/UAuthState.cs | 68 +++--- .../Authentication/UAuthStateChangeReason.cs | 2 +- .../Authentication/UAuthStateManager.cs | 16 +- .../CodeBeam.UltimateAuth.Client.csproj | 19 +- .../Components/UALoginDispatch.razor | 55 +++++ .../Components/UAuthApp.razor | 12 + .../Components/UAuthApp.razor.cs | 85 +++++++ .../Components/UAuthAppRoot.razor | 18 -- .../Components/UAuthAuthenticationState.razor | 13 -- .../UAuthAuthenticationState.razor.cs | 45 ---- .../Components/UAuthClientProvider.razor | 9 - .../Components/UAuthClientProvider.razor.cs | 41 ---- ...UALoginForm.razor => UAuthLoginForm.razor} | 8 +- ...nForm.razor.cs => UAuthLoginForm.razor.cs} | 15 +- .../Device/IDeviceIdProvider.cs | 2 +- .../Device/UAuthDeviceIdProvider.cs | 32 ++- .../Diagnostics/UAuthClientDiagnostics.cs | 8 +- .../Extensions/ServiceCollectionExtensions.cs | 34 +-- .../Infrastructure/IBrowserUAuthBridge.cs | 2 +- .../IUAuthClientBootstrapper.cs | 6 + .../{ => Login}/ClientLoginCapabilities.cs | 0 .../Login/UAuthLoginPageAttribute.cs | 6 + .../Login/UAuthLoginPageDiscovery.cs | 36 +++ ...onCoordinator.cs => SessionCoordinator.cs} | 4 +- .../UAuthClientBootstrapper.cs | 21 +- .../Infrastructure/UAuthRequestClient.cs | 22 +- .../Infrastructure/UAuthUrlBuilder.cs | 5 - .../Runtime/IUAuthClientBootstrapper.cs | 6 - .../Runtime/UAuthClientMarker.cs | 5 + .../Runtime/UAuthClientProductInfo.cs | 9 +- .../Runtime/UAuthClientProductInfoProvider.cs | 9 +- .../{wwwroot => TScripts}/uauth.js | 9 +- .../_Imports.razor | 4 + .../wwwroot/uauth.min.js | 1 + .../Auth/IAuthStateSnapshotFactory.cs | 8 + .../CodeBeam.UltimateAuth.Core.csproj | 4 +- .../Constants/UAuthConstants.cs | 37 ++++ .../Contracts/Auth/AuthIdentitySnapshot.cs | 15 ++ .../Contracts/Auth/AuthStateSnapshot.cs | 28 +++ .../Contracts/Session/AuthStateSnapshot.cs | 14 -- .../Session/SessionValidationResult.cs | 4 + .../Domain/Session/AuthSessionId.cs | 6 +- .../Domain/Session/ClaimsSnapshot.cs | 10 + .../Domain/Session/SessionChainId.cs | 6 +- .../Domain/Session/SessionRootId.cs | 6 +- .../Extensions/ClaimsSnapshotExtensions.cs | 23 +- .../AuthSessionIdJsonConverter.cs | 26 +++ .../Authority/DeviceMismatchPolicy.cs | 30 --- .../Authority/UAuthModeOperationPolicy.cs | 38 ---- .../SessionChainIdJsonConverter.cs | 26 +++ .../SessionRootIdJsonConverter.cs | 26 +++ .../Infrastructure/TenantKeyJsonConverter.cs | 29 +++ .../MultiTenancy/TenantKey.cs | 5 +- .../Auth/AuthStateSnapshotFactory.cs | 42 ++++ .../Auth/ClientProfileReader.cs | 11 +- .../UAuthAuthenticationExtension.cs | 9 +- .../UAuthAuthenticationHandler.cs | 78 ++++++- ...cs => UAuthAuthenticationSchemeOptions.cs} | 4 +- .../CodeBeam.UltimateAuth.Server.csproj | 3 +- ...okieDefaults.cs => UAuthSchemeDefaults.cs} | 2 +- .../Endpoints/RefreshEndpointHandler.cs | 1 - .../Endpoints/ValidateEndpointHandler.cs | 13 +- .../EndpointRouteBuilderExtensions.cs | 2 +- .../HttpContextReturnUrlExtensions.cs | 7 +- .../HttpContextSessionExtensions.cs | 6 +- .../Extensions/HttpContextTenantExtensions.cs | 6 +- .../Extensions/HttpContextUserExtensions.cs | 6 +- .../Extensions/ServiceCollectionExtensions.cs | 24 +- .../UAuthApplicationBuilderExtensions.cs | 10 +- .../Extensions/UAuthRazorExtensions.cs | 12 + .../Flows/Refresh/RefreshResponseWriter.cs | 5 +- .../Infrastructure/Device/DeviceResolver.cs | 6 +- .../Session/SessionContextAccessor.cs | 5 +- .../Session/SessionContextItemKeys.cs | 6 - .../User/HttpContextCurrentUser.cs | 3 +- .../Infrastructure/User/UAuthUserAccessor.cs | 7 +- .../SessionResolutionMiddleware.cs | 5 +- .../Middlewares/TenantMiddleware.cs | 8 +- .../Middlewares/UserMiddleware.cs | 2 - .../Options/UAuthNavigationOptions.cs | 17 ++ .../Options/UAuthServerOptions.cs | 4 + .../IUAuthServerProductInfoProvider.cs | 6 + .../Runtime/UAuthServerProductInfo.cs | 17 +- .../Runtime/UAuthServerProductInfoProvider.cs | 29 +++ .../Services/UAuthSessionValidator.cs | 2 +- ...ltimateAuth.Authorization.Contracts.csproj | 4 +- ...UltimateAuth.Authorization.InMemory.csproj | 4 +- .../InMemoryAuthorizationSeedContributor.cs | 4 + ...ltimateAuth.Authorization.Reference.csproj | 4 +- .../AuthorizationClaimsProvider.cs | 2 + ...CodeBeam.UltimateAuth.Authorization.csproj | 4 +- ....UltimateAuth.Credentials.Contracts.csproj | 3 +- ...uth.Credentials.EntityFrameworkCore.csproj | 3 +- ...m.UltimateAuth.Credentials.InMemory.csproj | 3 +- ....UltimateAuth.Credentials.Reference.csproj | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../PasswordUserLifecycleIntegration.cs | 2 +- .../CodeBeam.UltimateAuth.Credentials.csproj | 3 +- .../CodeBeam.UltimateAuth.Policies.csproj | 4 +- ...deBeam.UltimateAuth.Security.Argon2.csproj | 3 +- .../Dtos/PrimaryUserIdentifiers.cs | 10 + .../Infrastructure/InMemoryUserIdProvider.cs | 4 +- .../InMemoryUserSeedContributor.cs | 36 ++- .../Extensions/ServiceCollectonExtensions.cs | 1 + .../PrimaryUserIdentifierProvider.cs | 31 +++ .../Services/UserApplicationService.cs | 1 - .../IPrimaryUserIdentifierProvider.cs | 10 + .../Abstractions/IUserLifecycleIntegration.cs | 2 +- .../Client/AuthStateSnapshotFactoryTests.cs | 53 +++++ .../BlazorServerSessionCoordinatorTests.cs | 88 -------- .../Client/SessionCoordinatorTests.cs | 72 ++++++ .../Client/UAuthClientBootstrapperTests.cs | 48 ++++ .../Client/UAuthStateManagerTests.cs | 89 ++++++++ .../Client/UAuthStateTests.cs | 95 ++++++++ .../Helpers/TestHttpContext.cs | 6 +- .../Helpers/TestUsers.cs | 9 + .../Server/LoginOrchestratorTests.cs | 21 +- .../Server/RedirectTests.cs | 3 +- 148 files changed, 1874 insertions(+), 964 deletions(-) delete mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs create mode 100644 src/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj create mode 100644 src/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor delete mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor delete mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor delete mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs rename src/CodeBeam.UltimateAuth.Client/Components/{UALoginForm.razor => UAuthLoginForm.razor} (76%) rename src/CodeBeam.UltimateAuth.Client/Components/{UALoginForm.razor.cs => UAuthLoginForm.razor.cs} (89%) create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs rename src/CodeBeam.UltimateAuth.Client/Infrastructure/{ => Login}/ClientLoginCapabilities.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageAttribute.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs rename src/CodeBeam.UltimateAuth.Client/Infrastructure/{BlazorServerSessionCoordinator.cs => SessionCoordinator.cs} (92%) rename src/CodeBeam.UltimateAuth.Client/{Runtime => Infrastructure}/UAuthClientBootstrapper.cs (52%) delete mode 100644 src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientMarker.cs rename src/CodeBeam.UltimateAuth.Client/{wwwroot => TScripts}/uauth.js (95%) create mode 100644 src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthStateSnapshotFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthStateSnapshot.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthSessionIdJsonConverter.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionRootIdJsonConverter.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/TenantKeyJsonConverter.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs rename src/CodeBeam.UltimateAuth.Server/Authentication/{UAuthAuthenticationCookieOptions.cs => UAuthAuthenticationSchemeOptions.cs} (65%) rename src/CodeBeam.UltimateAuth.Server/Defaults/{UAuthCookieDefaults.cs => UAuthSchemeDefaults.cs} (75%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthNavigationOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Runtime/IUAuthServerProductInfoProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfoProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/PrimaryUserIdentifiers.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IPrimaryUserIdentifierProvider.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs delete mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientBootstrapperTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestUsers.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index f86cfc57..350cadd2 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -16,6 +16,7 @@ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index fac1f58d..a363c1bd 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -4,13 +4,13 @@ net10.0 enable enable - 0.0.1-preview + 0.0.1 true - - + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor index 0720040e..7f12ea3d 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor @@ -24,7 +24,7 @@ - + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index f978c35e..b1bff61d 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -37,14 +37,14 @@ return; } - + Welcome to UltimateAuth! Login - + Programmatic Pkce Login diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor index 91968d6b..d5f439a2 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor @@ -1,8 +1,8 @@ - + - + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 4ea4eb47..1b7b6973 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -3,8 +3,6 @@ using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; @@ -15,7 +13,6 @@ using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; -using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference; using CodeBeam.UltimateAuth.Users.Reference.Extensions; @@ -36,9 +33,9 @@ builder.Services .AddAuthentication(options => { - options.DefaultAuthenticateScheme = UAuthCookieDefaults.AuthenticationScheme; - options.DefaultSignInScheme = UAuthCookieDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = UAuthCookieDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = UAuthSchemeDefaults.AuthenticationScheme; + options.DefaultSignInScheme = UAuthSchemeDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = UAuthSchemeDefaults.AuthenticationScheme; }) .AddUAuthCookies(); @@ -46,8 +43,6 @@ builder.Services.AddHttpContextAccessor(); -builder.Services.AddUltimateAuth(); - builder.Services.AddUltimateAuthServer(o => { o.Diagnostics.EnableRefreshHeaders = true; //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); @@ -110,12 +105,10 @@ app.UseHttpsRedirection(); app.UseCors("WasmSample"); -app.UseUltimateAuthServer(); -app.UseAuthentication(); -app.UseAuthorization(); +app.UseUltimateAuthWithAspNetCore(); app.UseAntiforgery(); -app.MapUAuthEndpoints(); +app.MapUltimateAuthEndpoints(); app.MapStaticAssets(); app.MapControllers(); 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 index d69732e0..3b3f8455 100644 --- 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 @@ -1,16 +1,16 @@  - net9.0 + net10.0 enable enable - 0.0.1-preview + 0.0.1 - - - + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor index 7e2b73d4..b2e336d0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor @@ -20,7 +20,7 @@ - + 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 index a31ffa36..92e6700c 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -1,13 +1,6 @@ @inherits LayoutComponentBase @inject ISnackbar Snackbar - - - - - - - @Body
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 deleted file mode 100644 index 389a4569..00000000 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs +++ /dev/null @@ -1,12 +0,0 @@ -using MudBlazor; - -namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Layout -{ - public partial class MainLayout - { - private void HandleReauth() - { - Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); - } - } -} 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 index fc6ba3df..14f3284a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -1,109 +1,109 @@ -@page "/" -@page "/login" -@using CodeBeam.UltimateAuth.Client -@using CodeBeam.UltimateAuth.Client.Authentication -@using CodeBeam.UltimateAuth.Client.Device -@using CodeBeam.UltimateAuth.Client.Diagnostics -@using CodeBeam.UltimateAuth.Client.Runtime -@using CodeBeam.UltimateAuth.Core.Abstractions -@using CodeBeam.UltimateAuth.Core.Runtime -@using CodeBeam.UltimateAuth.Server.Abstractions -@using CodeBeam.UltimateAuth.Server.Infrastructure -@using CodeBeam.UltimateAuth.Server.Services -@inject IUAuthStateManager StateManager -@inject IUAuthFlowService Flow -@inject ISnackbar Snackbar -@inject ISessionQueryService SessionQuery -@inject IFlowCredentialResolver CredentialResolver -@inject IClock Clock -@inject IUAuthCookieManager CookieManager -@inject IHttpContextAccessor HttpContextAccessor -@inject IUAuthClient UAuth -@inject NavigationManager Nav -@inject IUAuthClientProductInfoProvider ClientProductInfo -@inject AuthenticationStateProvider AuthStateProvider +@page "/home" +@using System.Security.Claims +@attribute [Authorize] + +@inject AuthenticationStateProvider AuthProvider +@inject IUAuthClient UAuthClient @inject UAuthClientDiagnostics Diagnostics -@inject IDeviceIdProvider DeviceIdProvider + + + + -
- - - - Welcome to UltimateAuth! - - - Login - - - - - Validate - Logout - Refresh - - - - Programmatic Login - GetMe - Change User Inactive - - - - @ClientProductInfo.Get().ProductName v @ClientProductInfo.Get().Version - Client Profile: @ClientProductInfo.Get().ClientProfile.ToString() - - - - State of Authentication: - @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name) - UAuthState @(StateManager.State.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(StateManager.State.UserKey) - - - Authorized context is shown. @context?.User?.Identity?.IsAuthenticated - - - Not Authorized context is shown. - - - - - - This is Admin content. - - - - - - - UltimateAuth Client Diagnostics - - - - Started: @Diagnostics.StartCount - @Diagnostics.StartedAt - Stopped: @Diagnostics.StopCount - @Diagnostics.StoppedAt - Terminated: @Diagnostics.TerminatedCount - @Diagnostics.TerminatedAt (@Diagnostics.TerminationReason.ToString()) - - - - Refresh Attempts: @Diagnostics.RefreshAttemptCount - Auto: @Diagnostics.AutomaticRefreshCount - Manual: @Diagnostics.ManualRefreshCount - - - Touched Success: @Diagnostics.RefreshTouchedCount - - - No-Op Success: @Diagnostics.RefreshNoOpCount - - - ReauthRequired: @Diagnostics.RefreshReauthRequiredCount - - - Unknown: @Diagnostics.RefreshUnknownCount - + + @(AuthState?.Identity?.UserKey.Value.Substring(0, 2).ToUpper()) + + + + + ASP.NET Core State + IsAuthenticated: @_aspNetCoreState?.Identity?.IsAuthenticated + User Id: @_aspNetCoreState?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value + User Name: @_aspNetCoreState?.Identity?.Name + Authentication Type: @_aspNetCoreState?.Identity?.AuthenticationType + Is Admin: @_aspNetCoreState?.IsInRole("Admin") + + + + UAuth State + IsAuthenticated: @AuthState.IsAuthenticated + UserKey: @AuthState?.Identity?.UserKey.Value + User Name: @AuthState?.Identity?.PrimaryUserName + Primary Email: @AuthState?.Identity?.PrimaryEmail + Primary Phone: @AuthState?.Identity?.PrimaryPhone + Tenant: @AuthState?.Identity?.Tenant.Value + Authenticated At: @AuthState?.Identity?.AuthenticatedAt + Last Validated At: @AuthState?.LastValidatedAt + Is Admin: @AuthState.IsInRole("Admin") + Roles: @string.Join(", ", AuthState.Claims.Roles) - - + + + + + + User Operations + + + + + + + Add User + + + + Assign Role + + + + + Change Password + -
+ + Manual Refresh + + + + Logout + + + + +
+ + + + UltimateAuth Client Diagnostics + + + + Started: @Diagnostics.StartCount - @Diagnostics.StartedAt + Stopped: @Diagnostics.StopCount - @Diagnostics.StoppedAt + Terminated: @Diagnostics.TerminatedCount - @Diagnostics.TerminatedAt (@Diagnostics.TerminationReason.ToString()) + + + + Refresh Attempts: @Diagnostics.RefreshAttemptCount + Auto: @Diagnostics.AutomaticRefreshCount + Manual: @Diagnostics.ManualRefreshCount + + + Touched Success: @Diagnostics.RefreshTouchedCount + + + No-Op Success: @Diagnostics.RefreshNoOpCount + + + ReauthRequired: @Diagnostics.RefreshReauthRequiredCount + + + Unknown: @Diagnostics.RefreshUnknownCount + + + +
+
+
\ No newline at end of file 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 index 3e27d40a..1d314585 100644 --- 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 @@ -1,144 +1,44 @@ using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; -using MudBlazor; +using System.Security.Claims; namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; public partial class Home { - private string? _username; - private string? _password; + private ClaimsPrincipal? _aspNetCoreState; - private UALoginForm _form = null!; + [CascadingParameter] + public UAuthState AuthState { get; set; } = default!; - private AuthenticationState _authState = null!; + [CascadingParameter] + Task AuthenticationStateTask { get; set; } = default!; protected override async Task OnInitializedAsync() { + var state = await AuthenticationStateTask; + _aspNetCoreState = state.User; Diagnostics.Changed += OnDiagnosticsChanged; } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - await StateManager.EnsureAsync(); - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); - StateHasChanged(); - } - } - private void OnDiagnosticsChanged() { InvokeAsync(StateHasChanged); } - private async Task ProgrammaticLogin() - { - var deviceId = await DeviceIdProvider.GetOrCreateAsync(); - var request = new LoginRequest - { - Identifier = "admin", - Secret = "admin", - Device = DeviceContext.FromDeviceId(deviceId), - }; - await UAuth.Flows.LoginAsync(request); - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); - } - - private async Task ValidateAsync() - { - var result = await UAuth.Flows.ValidateAsync(); - - Snackbar.Add( - result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", - result.IsValid ? Severity.Success : Severity.Error); - } - - private async Task LogoutAsync() - { - await UAuth.Flows.LogoutAsync(); - Snackbar.Add("Logged out", Severity.Success); - } + private async Task Logout() + => await UAuthClient.Flows.LogoutAsync(); - private async Task RefreshAsync() - { - await UAuth.Flows.RefreshAsync(); - } + private async Task RefreshSession() + => await UAuthClient.Flows.RefreshAsync(false); - private async Task HandleGetMe() - { - var profileResult = await UAuth.Users.GetMeAsync(); - if (profileResult.Ok) - { - var profile = profileResult.Value; - Snackbar.Add($"User Profile: {profile?.UserName} ({profile?.DisplayName})", Severity.Info); - } - else - { - Snackbar.Add($"Failed to get profile: {profileResult.Error}", Severity.Error); - } - } - - private async Task ChangeUserInactive() - { - ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest - { - UserKey = UserKey.FromString("user"), - NewStatus = UserStatus.Disabled - }; - var result = await UAuth.Users.ChangeStatusAdminAsync(request); - if (result.Ok) - { - Snackbar.Add($"User is disabled.", Severity.Info); - } - else - { - Snackbar.Add($"Failed to change user status.", Severity.Error); - } - } - - protected override void OnAfterRender(bool firstRender) - { - if (firstRender) - { - var uri = Nav.ToAbsoluteUri(Nav.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - - if (query.TryGetValue("error", out var error)) - { - ShowLoginError(error.ToString()); - ClearQueryString(); - } - } - } - - private void ShowLoginError(string code) - { - var message = code switch - { - "invalid" => "Invalid username or password.", - "locked" => "Your account is locked.", - "mfa" => "Multi-factor authentication required.", - _ => "Login failed." - }; - - Snackbar.Add(message, Severity.Error); - } - - private void ClearQueryString() - { - var uri = new Uri(Nav.Uri); - var clean = uri.GetLeftPart(UriPartial.Path); - Nav.NavigateTo(clean, replace: true); - } + private Task CreateUser() => Task.CompletedTask; + private Task AssignRole() => Task.CompletedTask; + private Task ChangePassword() => Task.CompletedTask; public void Dispose() { 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..41a7e106 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Constants; + +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..11ced661 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -0,0 +1,61 @@ +@page "/login" +@attribute [UAuthLoginPage] + +@inject IUAuthClient UAuth +@inject AuthenticationStateProvider AuthStateProvider +@inject ISnackbar Snackbar +@inject NavigationManager Nav +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject UAuthClientDiagnostics Diagnostics + +
+ + + + Welcome to UltimateAuth! + + + Login + + + + + Validate + Logout + Refresh + + + + Programmatic Login + GetMe + Change User Inactive + + + + @_productInfo?.ProductName v @_productInfo?.Version + Client Profile: @_productInfo?.ClientProfile.ToString() + Framework Description: @_productInfo?.FrameworkDescription + + + + State of Authentication: + @(_aspNetCoreState?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_aspNetCoreState?.Identity?.Name) + UAuthState @(AuthState.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(AuthState.Identity?.UserKey) + + + Authorized context is shown. @context?.User?.Identity?.IsAuthenticated + + + Not Authorized context is shown. + + + + + + This is Admin content. + + + + +
\ No newline at end of file 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..f5e81d06 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -0,0 +1,137 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using MudBlazor; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class Login +{ + private string? _username; + private string? _password; + private ClaimsPrincipal? _aspNetCoreState; + private UAuthClientProductInfo? _productInfo; + + [CascadingParameter] + public UAuthState AuthState { get; set; } = default!; + + [CascadingParameter] + Task AuthenticationStateTask { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + var state = await AuthenticationStateTask; + _aspNetCoreState = state.User; + Diagnostics.Changed += OnDiagnosticsChanged; + _productInfo = ClientProductInfoProvider.Get(); + } + + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } + + private async Task ProgrammaticLogin() + { + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); + var request = new LoginRequest + { + Identifier = "admin", + Secret = "admin", + Device = DeviceContext.FromDeviceId(deviceId), + }; + await UAuth.Flows.LoginAsync(request, "/home"); + } + + private async Task ValidateAsync() + { + var result = await UAuth.Flows.ValidateAsync(); + + Snackbar.Add( + result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", + result.IsValid ? Severity.Success : Severity.Error); + } + + private async Task LogoutAsync() + { + await UAuth.Flows.LogoutAsync(); + Snackbar.Add("Logged out", Severity.Success); + } + + private async Task RefreshAsync() + { + await UAuth.Flows.RefreshAsync(); + } + + private async Task HandleGetMe() + { + var profileResult = await UAuth.Users.GetMeAsync(); + if (profileResult.Ok) + { + var profile = profileResult.Value; + Snackbar.Add($"User Profile: {profile?.UserName} ({profile?.DisplayName})", Severity.Info); + } + else + { + Snackbar.Add($"Failed to get profile: {profileResult.Error}", Severity.Error); + } + } + + private async Task ChangeUserInactive() + { + ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest + { + UserKey = UserKey.FromString("user"), + NewStatus = UserStatus.Disabled + }; + var result = await UAuth.Users.ChangeStatusAdminAsync(request); + if (result.Ok) + { + Snackbar.Add($"User is disabled.", Severity.Info); + } + else + { + Snackbar.Add($"Failed to change user status.", Severity.Error); + } + } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("error", out var error)) + { + ShowLoginError(error.ToString()); + ClearQueryString(); + } + } + } + + private void ShowLoginError(string code) + { + var message = code switch + { + "invalid" => "Invalid username or password.", + "locked" => "Your account is locked.", + "mfa" => "Multi-factor authentication required.", + _ => "Login failed." + }; + + Snackbar.Add(message, Severity.Error); + } + + private void ClearQueryString() + { + var uri = new Uri(Nav.Uri); + var clean = uri.GetLeftPart(UriPartial.Path); + Nav.NavigateTo(clean, replace: true); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor index 70173134..4c6c1796 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor @@ -1,10 +1,22 @@ -@using CodeBeam.UltimateAuth.Client.Components +@inject ISnackbar Snackbar - - + + + + + + + - + + +@code { + private void HandleReauth() + { + Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor index f0e42821..877928bb 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor @@ -2,6 +2,7 @@ @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 @@ -13,6 +14,8 @@ @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 MudBlazor @using MudExtensions diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 0d72839b..a38eb66f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,35 +1,24 @@ -using CodeBeam.UltimateAuth.Authorization.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client.Extensions; -using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Security.Argon2; -using CodeBeam.UltimateAuth.Server.Authentication; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; -using CodeBeam.UltimateAuth.Users.InMemory; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; -using CodeBeam.UltimateAuth.Users.Reference; using CodeBeam.UltimateAuth.Users.Reference.Extensions; -using Microsoft.AspNetCore.Components; +using CodeBeam.UltimateAuth.Client; using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddCircuitOptions(options => @@ -42,26 +31,15 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); -builder.Services - .AddAuthentication(options => - { - options.DefaultAuthenticateScheme = UAuthCookieDefaults.AuthenticationScheme; - options.DefaultSignInScheme = UAuthCookieDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = UAuthCookieDefaults.AuthenticationScheme; - }) - .AddUAuthCookies(); - -builder.Services.AddAuthorization(); - -builder.Services.AddUltimateAuth(); - builder.Services.AddUltimateAuthServer(o => { o.Diagnostics.EnableRefreshHeaders = 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.AuthResponse.Login.AllowReturnUrlOverride = true; + //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); + //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() @@ -75,63 +53,37 @@ builder.Services.AddUltimateAuthClient(o => { - //o.Refresh.Interval = TimeSpan.FromSeconds(5); + //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; }); -builder.Services.AddScoped(sp => -{ - var navigation = sp.GetRequiredService(); - - return new HttpClient - { - BaseAddress = new Uri(navigation.BaseUri) - }; -}); - -//builder.Services.AddHttpClient("AuthApi", client => -//{ -// client.BaseAddress = new Uri("https://localhost:7213"); -//}) -//.ConfigurePrimaryHttpMessageHandler(() => -//{ -// return new HttpClientHandler -// { -// UseCookies = true -// }; -//}); - var app = builder.Build(); -// Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 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.UseStaticFiles(); -app.UseUltimateAuthServer(); -app.UseAuthentication(); -app.UseAuthorization(); +app.UseUltimateAuthWithAspNetCore(); app.UseAntiforgery(); -app.MapUAuthEndpoints(); +app.MapUltimateAuthEndpoints(); app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .AddUltimateAuthClientRoutes(typeof(UAuthClientMarker).Assembly); app.Run(); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor index 343dbef5..58128e80 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor @@ -1,11 +1,11 @@ -@using CodeBeam.UltimateAuth.Client.Components +@inject ISnackbar Snackbar - + @@ -18,4 +18,11 @@ - \ No newline at end of file + + +@code { + private void HandleReauth() + { + Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); + } +} \ No newline at end of file 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 index 36d7dae1..3673ab70 100644 --- 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 @@ -4,16 +4,16 @@ net10.0 enable enable - 0.0.1-preview + 0.0.1 - + - + 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 index f9cd744d..d904484d 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -3,11 +3,11 @@ @using CodeBeam.UltimateAuth.Client.Authentication @using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Infrastructure @using CodeBeam.UltimateAuth.Client.Runtime @using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Runtime @inject IUAuthStateManager StateManager -@inject IHttpClientFactory HttpClientFactory @inject IUAuthProductInfoProvider ProductInfo @inject ISnackbar Snackbar @inject IUAuthClient UAuthClient @@ -20,14 +20,14 @@
- + Welcome to UltimateAuth! Login - + Validate @@ -49,7 +49,7 @@ StateHasChanged Refresh Auth State State of Authentication: - From UltimateAuth: @(Auth?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(Auth?.UserKey) + From UltimateAuth: @(Auth?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(Auth?.Identity?.UserKey) From ASPNET Core: @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name) @@ -103,43 +103,43 @@ Ping ResourceApi @code { - private string? _result; - private Severity _severity = Severity.Info; + // private string? _result; + // private Severity _severity = Severity.Info; private async Task CallHub() { - try - { - var client = HttpClientFactory.CreateClient("UAuthHub"); - var response = await client.GetStringAsync("/health"); - - _result = $"UAuthHub response: {response}"; - _severity = Severity.Success; - } - catch (Exception ex) - { - _result = $"UAuthHub error: {ex.Message}"; - _severity = Severity.Error; - } - Snackbar.Add(_result, _severity); + // try + // { + // var client = HttpClientFactory.CreateClient("UAuthHub"); + // var response = await client.GetStringAsync("/health"); + + // _result = $"UAuthHub response: {response}"; + // _severity = Severity.Success; + // } + // catch (Exception ex) + // { + // _result = $"UAuthHub error: {ex.Message}"; + // _severity = Severity.Error; + // } + // Snackbar.Add(_result, _severity); } private async Task CallApi() { - try - { - var client = HttpClientFactory.CreateClient("ResourceApi"); - var response = await client.GetStringAsync("/health"); - - _result = $"ResourceApi response: {response}"; - _severity = Severity.Success; - } - catch (Exception ex) - { - _result = $"ResourceApi error: {ex.Message}"; - _severity = Severity.Error; - } - Snackbar.Add(_result, _severity); + // try + // { + // var client = HttpClientFactory.CreateClient("ResourceApi"); + // var response = await client.GetStringAsync("/health"); + + // _result = $"ResourceApi response: {response}"; + // _severity = Severity.Success; + // } + // catch (Exception ex) + // { + // _result = $"ResourceApi error: {ex.Message}"; + // _severity = Severity.Error; + // } + // Snackbar.Add(_result, _severity); } } 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 index e9530d89..55315ca7 100644 --- 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 @@ -16,8 +16,6 @@ public partial class Home private string? _username; private string? _password; - private UALoginForm _form = null!; - private AuthenticationState _authState = null!; protected override async Task OnInitializedAsync() diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 1a438a17..51de6048 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -26,14 +26,14 @@ builder.Services.AddMudServices(); builder.Services.AddMudExtensions(); -builder.Services.AddHttpClient("UAuthHub", client => -{ - client.BaseAddress = new Uri("https://localhost:6110"); -}); - -builder.Services.AddHttpClient("ResourceApi", client => -{ - client.BaseAddress = new Uri("https://localhost:6120"); -}); +//builder.Services.AddHttpClient("UAuthHub", client => +//{ +// client.BaseAddress = new Uri("https://localhost:6110"); +//}); + +//builder.Services.AddHttpClient("ResourceApi", client => +//{ +// client.BaseAddress = new Uri("https://localhost:6120"); +//}); await builder.Build().RunAsync(); 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 index 3c50485b..d0fc7487 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html @@ -32,7 +32,7 @@ - + diff --git a/src/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj b/src/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj new file mode 100644 index 00000000..39441148 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + Exe + enable + enable + + + + + + + diff --git a/src/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs b/src/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs new file mode 100644 index 00000000..a42d227c --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs index a48b5bdf..f30e0447 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Client.Authentication; +namespace CodeBeam.UltimateAuth.Client; /// /// Orchestrates the lifecycle of UAuthState. @@ -16,7 +16,7 @@ public interface IUAuthStateManager /// Ensures the authentication state is valid. /// May call server validate/refresh if needed. /// - Task EnsureAsync(CancellationToken ct = default); + Task EnsureAsync(bool force = false, CancellationToken ct = default); /// /// Called after a successful login. diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs index 57c64931..5339f0f4 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Components.Authorization; -namespace CodeBeam.UltimateAuth.Client.Authentication; +namespace CodeBeam.UltimateAuth.Client; internal sealed class UAuthAuthenticationStateProvider : AuthenticationStateProvider { @@ -17,5 +17,4 @@ public override Task GetAuthenticationStateAsync() var principal = _stateManager.State.ToClaimsPrincipal(); return Task.FromResult(new AuthenticationState(principal)); } - } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs index c8733f43..ed025e7c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs @@ -1,13 +1,12 @@ using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Client.Authentication; +namespace CodeBeam.UltimateAuth.Client; internal sealed class UAuthCascadingStateProvider : CascadingValueSource, IDisposable { private readonly IUAuthStateManager _stateManager; - public UAuthCascadingStateProvider(IUAuthStateManager stateManager) - : base(() => stateManager.State, isFixed: false) + public UAuthCascadingStateProvider(IUAuthStateManager stateManager) : base(() => stateManager.State, isFixed: false) { _stateManager = stateManager; _stateManager.State.Changed += OnStateChanged; diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs index 405abfc1..f75f29ac 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs @@ -1,9 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Extensions; using System.Security.Claims; -namespace CodeBeam.UltimateAuth.Client.Authentication; +namespace CodeBeam.UltimateAuth.Client; /// /// Represents the client-side authentication snapshot for UltimateAuth. @@ -15,56 +15,33 @@ public sealed class UAuthState { private UAuthState() { } - public bool IsAuthenticated { get; private set; } - - public UserKey? UserKey { get; private set; } - - public TenantKey Tenant { get; private set; } - - /// - /// When this authentication snapshot was created. - /// - public DateTimeOffset? AuthenticatedAt { get; private set; } + public AuthIdentitySnapshot? Identity { get; private set; } + public ClaimsSnapshot Claims { get; private set; } = ClaimsSnapshot.Empty; - /// - /// When this snapshot was last validated or refreshed. - /// public DateTimeOffset? LastValidatedAt { get; private set; } /// - /// Indicates whether the snapshot may be stale - /// (e.g. after navigation, reload, or time-based heuristics). + /// Indicates whether the snapshot may be stale (e.g. after navigation, reload, or time-based heuristics). /// public bool IsStale { get; private set; } - public ClaimsSnapshot Claims { get; private set; } = ClaimsSnapshot.Empty; - public event Action? Changed; + public bool IsAuthenticated => Identity is not null; + public static UAuthState Anonymous() => new(); internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt) { - if (string.IsNullOrWhiteSpace(snapshot.UserKey)) - { - Clear(); - return; - } - - UserKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString(snapshot.UserKey); - Tenant = snapshot.Tenant; + Identity = snapshot.Identity; Claims = snapshot.Claims; - IsAuthenticated = true; - - AuthenticatedAt = snapshot.AuthenticatedAt; - LastValidatedAt = validatedAt; IsStale = false; + LastValidatedAt = validatedAt; Changed?.Invoke(UAuthStateChangeReason.Authenticated); } - internal void MarkValidated(DateTimeOffset now) { if (!IsAuthenticated) @@ -87,32 +64,37 @@ internal void MarkStale() internal void Clear() { + Identity = null; Claims = ClaimsSnapshot.Empty; - UserKey = null; - IsAuthenticated = false; - - AuthenticatedAt = null; - LastValidatedAt = null; IsStale = false; Changed?.Invoke(UAuthStateChangeReason.Cleared); } + public bool IsInRole(string role) => IsAuthenticated && Claims.IsInRole(role); + + public bool HasPermission(string permission) => IsAuthenticated && Claims.HasPermission(permission); + + 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 = "UltimateAuth") { - if (!IsAuthenticated) + if (!IsAuthenticated || Identity is null) return new ClaimsPrincipal(new ClaimsIdentity()); - var identity = new ClaimsIdentity( - Claims.AsDictionary() - .Select(kv => new Claim(kv.Key, kv.Value)), - authenticationType); + 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/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs index ad2fa368..587115bf 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Client.Authentication; +namespace CodeBeam.UltimateAuth.Client; public enum UAuthStateChangeReason { diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs index 55e3f7e1..db7af1de 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Client.Runtime; -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; namespace CodeBeam.UltimateAuth.Client.Authentication; @@ -7,23 +6,20 @@ internal sealed class UAuthStateManager : IUAuthStateManager { private readonly IUAuthClient _client; private readonly IClock _clock; - private readonly IUAuthClientBootstrapper _bootstrapper; public UAuthState State { get; } = UAuthState.Anonymous(); - public UAuthStateManager(IUAuthClient client, IClock clock, IUAuthClientBootstrapper bootstrapper) + public UAuthStateManager(IUAuthClient client, IClock clock) { _client = client; _clock = clock; - _bootstrapper = bootstrapper; } - public async Task EnsureAsync(CancellationToken ct = default) - { - if (State.IsAuthenticated && !State.IsStale) + public async Task EnsureAsync(bool force = false, CancellationToken ct = default) + { + if (!force && State.IsAuthenticated && !State.IsStale) return; - await _bootstrapper.EnsureStartedAsync(); var result = await _client.Flows.ValidateAsync(); if (!result.IsValid || result.Snapshot == null) @@ -51,4 +47,6 @@ public void MarkStale() { State.MarkStale(); } + + public bool NeedsValidation => !State.IsAuthenticated || State.IsStale; } diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index 928068a8..b9203488 100644 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 @@ -28,6 +29,18 @@ + + + + + + + + true + PreserveNewest + + + @@ -35,4 +48,8 @@ + + + + diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor b/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor new file mode 100644 index 00000000..f2b7a79d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor @@ -0,0 +1,55 @@ +@page "/__uauth/login-redirect" + +@namespace CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Core.Constants +@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/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor new file mode 100644 index 00000000..a24e9179 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor @@ -0,0 +1,12 @@ +@namespace CodeBeam.UltimateAuth.Client + +@using Microsoft.AspNetCore.Components.Authorization +@inject IUAuthStateManager StateManager +@inject IUAuthClientBootstrapper Bootstrapper +@inject ISessionCoordinator Coordinator + + + + @ChildContent + + diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs new file mode 100644 index 00000000..c7bd0910 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client; + +public partial class UAuthApp +{ + private bool _initialized; + private bool _coordinatorStarted; + + [Parameter] + public RenderFragment ChildContent { get; set; } = default!; + + [Parameter] + public EventCallback OnReauthRequired { get; set; } + + protected override async Task OnInitializedAsync() + { + Coordinator.ReauthRequired += HandleReauthRequired; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _initialized) + return; + + _initialized = true; + + await Bootstrapper.EnsureStartedAsync(); + await StateManager.EnsureAsync(); + + if (StateManager.State.IsAuthenticated) + { + await Coordinator.StartAsync(); + _coordinatorStarted = true; + } + + StateManager.State.Changed += OnStateChanged; + + StateHasChanged(); + } + + private void OnStateChanged(UAuthStateChangeReason reason) + { + if (reason == UAuthStateChangeReason.MarkedStale) + { + _ = InvokeAsync(async () => + { + await StateManager.EnsureAsync(); + }); + } + + if (reason == UAuthStateChangeReason.Authenticated) + { + _ = InvokeAsync(async () => + { + await Coordinator.StartAsync(); + }); + } + + if (reason == UAuthStateChangeReason.Cleared) + { + _ = InvokeAsync(async () => + { + await Coordinator.StopAsync(); + }); + } + + InvokeAsync(StateHasChanged); + } + + private async void HandleReauthRequired() + { + if (OnReauthRequired.HasDelegate) + await OnReauthRequired.InvokeAsync(); + } + + public async ValueTask DisposeAsync() + { + StateManager.State.Changed -= OnStateChanged; + Coordinator.ReauthRequired -= HandleReauthRequired; + + if (_coordinatorStarted) + await Coordinator.StopAsync(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor deleted file mode 100644 index f0769183..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAppRoot.razor +++ /dev/null @@ -1,18 +0,0 @@ -@using CodeBeam.UltimateAuth.Client.Runtime -@inject IUAuthClientBootstrapper Bootstrapper - - - @ChildContent - - -@code { - [Parameter] public RenderFragment? ChildContent { get; set; } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender) - return; - - await Bootstrapper.EnsureStartedAsync(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor deleted file mode 100644 index 921b35e6..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor +++ /dev/null @@ -1,13 +0,0 @@ -@using CodeBeam.UltimateAuth.Client.Authentication -@using CodeBeam.UltimateAuth.Client.Runtime -@using CodeBeam.UltimateAuth.Core.Contracts -@using Microsoft.AspNetCore.Components.Authorization -@inject IUAuthStateManager StateManager -@inject AuthenticationStateProvider AuthStateProvider -@inject IUAuthClientBootstrapper Bootstrapper - - - - @ChildContent - - diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs deleted file mode 100644 index c02a9c5a..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthAuthenticationState.razor.cs +++ /dev/null @@ -1,45 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Authentication; -using Microsoft.AspNetCore.Components; - -namespace CodeBeam.UltimateAuth.Client.Components; - -public partial class UAuthAuthenticationState -{ - private bool _initialized; - private UAuthState _uauthState = UAuthState.Anonymous(); - - [Parameter] - public RenderFragment ChildContent { get; set; } = default!; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender) - return; - - if (_initialized) - return; - - _initialized = true; - //await Bootstrapper.EnsureStartedAsync(); - await StateManager.EnsureAsync(); - _uauthState = StateManager.State; - - StateManager.State.Changed += OnStateChanged; - } - - private void OnStateChanged(UAuthStateChangeReason _) - { - //StateManager.EnsureAsync(); - if (_ == UAuthStateChangeReason.MarkedStale) - { - StateManager.EnsureAsync(); - } - _uauthState = StateManager.State; - InvokeAsync(StateHasChanged); - } - - public void Dispose() - { - StateManager.State.Changed -= OnStateChanged; - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor deleted file mode 100644 index 89aeb7f6..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor +++ /dev/null @@ -1,9 +0,0 @@ -@namespace CodeBeam.UltimateAuth.Client -@using CodeBeam.UltimateAuth.Client.Abstractions -@using CodeBeam.UltimateAuth.Client.Device -@using CodeBeam.UltimateAuth.Client.Infrastructure -@inject IDeviceIdProvider DeviceIdProvider -@inject IBrowserUAuthBridge BrowserUAuthBridge -@inject ISessionCoordinator Coordinator - -@implements IAsyncDisposable diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs deleted file mode 100644 index 8bbcd7e2..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthClientProvider.razor.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.AspNetCore.Components; - -namespace CodeBeam.UltimateAuth.Client; - -// TODO: Add CircuitHandler to manage start/stop of coordinator in server-side Blazor -public partial class UAuthClientProvider : ComponentBase, IAsyncDisposable -{ - private bool _started; - - [Parameter] - public EventCallback OnReauthRequired { get; set; } - - protected override async Task OnInitializedAsync() - { - Coordinator.ReauthRequired += HandleReauthRequired; - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender || _started) - return; - - _started = true; - // TODO: Add device id auto creation for MVC, this is only for blazor. - var deviceId = await DeviceIdProvider.GetOrCreateAsync(); - await BrowserUAuthBridge.SetDeviceIdAsync(deviceId.Value); - await Coordinator.StartAsync(); - StateHasChanged(); - } - - private async void HandleReauthRequired() - { - if (OnReauthRequired.HasDelegate) - await OnReauthRequired.InvokeAsync(); - } - - public async ValueTask DisposeAsync() - { - await Coordinator.StopAsync(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor similarity index 76% rename from src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor rename to src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor index 2d3cafbc..75273832 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor @@ -1,8 +1,10 @@ @* TODO: Optional double-submit prevention for native form submit *@ @namespace CodeBeam.UltimateAuth.Client + @using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Options @using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Constants @using CodeBeam.UltimateAuth.Core.Contracts @using CodeBeam.UltimateAuth.Core.Options @using Microsoft.Extensions.Options @@ -13,15 +15,15 @@
- - + + @if (LoginType == UAuthLoginType.Pkce) { - + } @ChildContent diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs similarity index 89% rename from src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs rename to src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs index 8337cd2e..a820233d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginForm.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs @@ -1,6 +1,6 @@ -using CodeBeam.UltimateAuth.Client.Device; -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Components; @@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Client; -public partial class UALoginForm +public partial class UAuthLoginForm { [Inject] IDeviceIdProvider DeviceIdProvider { get; set; } = null!; private DeviceId? _deviceId; @@ -136,13 +136,13 @@ private string ResolvedEndpoint if (string.IsNullOrWhiteSpace(returnUrl)) return baseUrl; - return $"{baseUrl}?{(_credentials != null ? "hub=" + EffectiveHubSessionId + "&" : null)}returnUrl={Uri.EscapeDataString(returnUrl)}"; + return $"{baseUrl}?{(_credentials != null ? "hub=" + EffectiveHubSessionId + "&" : null)}{UAuthConstants.Query.ReturnUrl}={Uri.EscapeDataString(returnUrl)}"; } } private string EffectiveReturnUrl => !string.IsNullOrWhiteSpace(ReturnUrl) - ? ReturnUrl - : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl ?? string.Empty : Navigation.Uri; + ? ReturnUrl + : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl ?? string.Empty : Navigation.Uri; private HubSessionId? EffectiveHubSessionId { @@ -154,7 +154,7 @@ private HubSessionId? EffectiveHubSessionId var uri = Navigation.ToAbsoluteUri(Navigation.Uri); var query = QueryHelpers.ParseQuery(uri.Query); - if (query.TryGetValue("hub", out var hubValue) && CodeBeam.UltimateAuth.Core.Domain.HubSessionId.TryParse(hubValue, out var parsed)) + if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubValue) && CodeBeam.UltimateAuth.Core.Domain.HubSessionId.TryParse(hubValue, out var parsed)) { return parsed; } @@ -162,5 +162,4 @@ private HubSessionId? EffectiveHubSessionId return null; } } - } diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs index 5bc079ef..a9be9fdd 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Device; +namespace CodeBeam.UltimateAuth.Client; public interface IDeviceIdProvider { diff --git a/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs b/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs index b7152b00..5cadcb31 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs @@ -1,13 +1,13 @@ using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Client.Devices; +namespace CodeBeam.UltimateAuth.Client; public 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) @@ -21,18 +21,26 @@ public async ValueTask GetOrCreateAsync(CancellationToken ct = default if (_cached is not null) return _cached.Value; - var raw = await _storage.LoadAsync(ct); - - if (!string.IsNullOrWhiteSpace(raw)) + await _gate.WaitAsync(ct); + try { - _cached = DeviceId.Create(raw); - return _cached.Value; - } + var raw = await _storage.LoadAsync(ct); - var generated = _generator.Generate(); - await _storage.SaveAsync(generated.Value, ct); + if (!string.IsNullOrWhiteSpace(raw)) + { + _cached = DeviceId.Create(raw); + return _cached.Value; + } - _cached = generated; - return generated; + var generated = _generator.Generate(); + await _storage.SaveAsync(generated.Value, ct); + + _cached = generated; + return generated; + } + finally + { + _gate.Release(); + } } } diff --git a/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs index b136ef9e..f36f54bb 100644 --- a/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs +++ b/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs @@ -31,6 +31,13 @@ public sealed class UAuthClientDiagnostics public int RefreshReauthRequiredCount { get; private set; } public int RefreshUnknownCount { get; private set; } + public TimeSpan? RunningDuration => + StartedAt is null + ? null + : (IsStopped || IsTerminated + ? (StoppedAt ?? TerminatedAt) - StartedAt + : DateTimeOffset.UtcNow - StartedAt); + internal void MarkStarted() { StartedAt = DateTimeOffset.UtcNow; @@ -94,5 +101,4 @@ internal void MarkTerminated(CoordinatorTerminationReason reason) Interlocked.Increment(ref _terminatedCount); Changed?.Invoke(); } - } diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 451e8fc9..e820524e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -10,7 +10,6 @@ using CodeBeam.UltimateAuth.Client.Utilities; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -82,33 +81,24 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped(sp => - { - var options = sp.GetRequiredService>().Value; - - return options.ClientProfile == UAuthClientProfile.BlazorServer - ? sp.GetRequiredService() - : sp.GetRequiredService(); - }); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.AddScoped(); - services.AddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + //services.AddScoped(); + //services.AddScoped>(sp => sp.GetRequiredService()); return services; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs index d6ed1395..2098052a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; -internal interface IBrowserUAuthBridge +public interface IBrowserUAuthBridge { ValueTask SetDeviceIdAsync(string deviceId); } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs new file mode 100644 index 00000000..13b5b154 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/ClientLoginCapabilities.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs rename to src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/ClientLoginCapabilities.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageAttribute.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageAttribute.cs new file mode 100644 index 00000000..d42e1275 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageAttribute.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Client; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public sealed class UAuthLoginPageAttribute : Attribute +{ +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs new file mode 100644 index 00000000..171bbabd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/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. 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/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs rename to src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs index d74959bf..ad1f176b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BlazorServerSessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; -internal sealed class BlazorServerSessionCoordinator : ISessionCoordinator +internal sealed class SessionCoordinator : ISessionCoordinator { private readonly IUAuthClient _client; private readonly NavigationManager _navigation; @@ -20,7 +20,7 @@ internal sealed class BlazorServerSessionCoordinator : ISessionCoordinator public event Action? ReauthRequired; - public BlazorServerSessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics) + public SessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics) { _client = client; _navigation = navigation; diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs similarity index 52% rename from src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs rename to src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs index e6b12b35..c70a0375 100644 --- a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientBootstrapper.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs @@ -1,10 +1,6 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using CodeBeam.UltimateAuth.Client.Device; -using CodeBeam.UltimateAuth.Client.Infrastructure; - -// DeviceId is automatically created and managed by UAuthClientProvider. This class is for advanced situations. -namespace CodeBeam.UltimateAuth.Client.Runtime; +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); @@ -12,26 +8,21 @@ internal sealed class UAuthClientBootstrapper : IUAuthClientBootstrapper private readonly IDeviceIdProvider _deviceIdProvider; private readonly IBrowserUAuthBridge _browser; - private readonly ISessionCoordinator _coordinator; public bool IsStarted => _started; - public UAuthClientBootstrapper( - IDeviceIdProvider deviceIdProvider, - IBrowserUAuthBridge browser, - ISessionCoordinator coordinator) + public UAuthClientBootstrapper(IDeviceIdProvider deviceIdProvider, IBrowserUAuthBridge browser) { _deviceIdProvider = deviceIdProvider; _browser = browser; - _coordinator = coordinator; } - public async Task EnsureStartedAsync() + public async Task EnsureStartedAsync(CancellationToken ct = default) { if (_started) return; - await _gate.WaitAsync(); + await _gate.WaitAsync(ct); try { if (_started) @@ -39,7 +30,6 @@ public async Task EnsureStartedAsync() var deviceId = await _deviceIdProvider.GetOrCreateAsync(); await _browser.SetDeviceIdAsync(deviceId.Value); - await _coordinator.StartAsync(); _started = true; } @@ -48,5 +38,4 @@ public async Task EnsureStartedAsync() _gate.Release(); } } - } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs index 0f3ce69c..f0de6f16 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs @@ -1,7 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Options; -using CodeBeam.UltimateAuth.Core.Options; using Microsoft.Extensions.Options; using Microsoft.JSInterop; @@ -11,31 +9,37 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; internal sealed class UAuthRequestClient : IUAuthRequestClient { private readonly IJSRuntime _js; + IUAuthClientBootstrapper _bootstrapper; private UAuthClientOptions _options; - public UAuthRequestClient(IJSRuntime js, IOptions options) + public UAuthRequestClient(IJSRuntime js, IUAuthClientBootstrapper bootstrapper, IOptions options) { _js = js; + _bootstrapper = bootstrapper; _options = options.Value; } - public Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + public async Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - return _js.InvokeVoidAsync("uauth.post", ct, new + await _bootstrapper.EnsureStartedAsync(); + + await _js.InvokeVoidAsync("uauth.post", ct, new { url = endpoint, mode = "navigate", data = form, clientProfile = _options.ClientProfile.ToString() - }).AsTask(); + }); } 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, @@ -52,6 +56,8 @@ public async Task SendFormForJsonAsync(string endpoint, ID { ct.ThrowIfCancellationRequested(); + await _bootstrapper.EnsureStartedAsync(); + var postData = form ?? new Dictionary(); return await _js.InvokeAsync("uauth.post", ct, new @@ -68,6 +74,8 @@ public async Task SendJsonAsync(string endpoint, object? p { ct.ThrowIfCancellationRequested(); + await _bootstrapper.EnsureStartedAsync(); + return await _js.InvokeAsync("uauth.postJson", ct, new { url = endpoint, diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs index 7137175b..d5c03fe1 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -5,11 +5,6 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; internal static class UAuthUrlBuilder { - //public static string Combine(string authority, string relative) - //{ - // return authority.TrimEnd('/') + "/" + relative.TrimStart('/'); - //} - public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant) { var baseAuthority = authority.TrimEnd('/'); diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs deleted file mode 100644 index 448016e8..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientBootstrapper.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Client.Runtime; - -public interface IUAuthClientBootstrapper -{ - Task EnsureStartedAsync(); -} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientMarker.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientMarker.cs new file mode 100644 index 00000000..1c9c2333 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientMarker.cs @@ -0,0 +1,5 @@ +namespace CodeBeam.UltimateAuth.Client; + +public class UAuthClientMarker +{ +} diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs index fb7e4874..771c92c5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Client.Runtime; @@ -12,4 +13,10 @@ public sealed class UAuthClientProductInfo 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/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs index 696163b8..d939fda0 100644 --- a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs +++ b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs @@ -11,13 +11,20 @@ internal sealed class UAuthClientProductInfoProvider : IUAuthClientProductInfoPr 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 = options.Value.ClientProfile + ClientProfile = opts.ClientProfile, + + AutoRefreshEnabled = opts.AutoRefresh.Enabled, + RefreshInterval = opts.AutoRefresh.Interval, + ReauthBehavior = opts.Reauth.Behavior, + + FrameworkDescription = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription }; } diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js b/src/CodeBeam.UltimateAuth.Client/TScripts/uauth.js similarity index 95% rename from src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js rename to src/CodeBeam.UltimateAuth.Client/TScripts/uauth.js index 0aa48f70..c627bab1 100644 --- a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.js +++ b/src/CodeBeam.UltimateAuth.Client/TScripts/uauth.js @@ -42,13 +42,14 @@ window.uauth.submitForm = function (form) { throw new Error("UAuth deviceId is not initialized."); } - //if (!form.querySelector("input[name='__uauth_device']")) { - const udid = document.createElement("input"); + let udid = form.querySelector("input[name='__uauth_device']"); + if (!udid) { + udid = document.createElement("input"); udid.type = "hidden"; udid.name = "__uauth_device"; - udid.value = window.uauth.deviceId; form.appendChild(udid); - //} + } + udid.value = window.uauth.deviceId; form.submit(); }; diff --git a/src/CodeBeam.UltimateAuth.Client/_Imports.razor b/src/CodeBeam.UltimateAuth.Client/_Imports.razor index 4b46211d..34f03595 100644 --- a/src/CodeBeam.UltimateAuth.Client/_Imports.razor +++ b/src/CodeBeam.UltimateAuth.Client/_Imports.razor @@ -1 +1,5 @@ @using Microsoft.JSInterop + +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Abstractions +@using CodeBeam.UltimateAuth.Client.Infrastructure diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js new file mode 100644 index 00000000..0a57addb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/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.post=async function(n){const{url:f,mode:s,data:t,expectJson:h,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;if(h)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} \ No newline at end of file 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/CodeBeam.UltimateAuth.Core.csproj b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj index 5a05299e..4e15f94d 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -4,8 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 diff --git a/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs new file mode 100644 index 00000000..e7bb1179 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs @@ -0,0 +1,37 @@ +namespace CodeBeam.UltimateAuth.Core.Constants; + +public static class UAuthConstants +{ + public static class HttpItems + { + public const string SessionContext = "__UAuth.SessionContext"; + 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/Contracts/Auth/AuthIdentitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs new file mode 100644 index 00000000..c62d4b68 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs @@ -0,0 +1,15 @@ +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; } +} 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/Session/AuthStateSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs deleted file mode 100644 index c5bff8da..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthStateSnapshot.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed record AuthStateSnapshot -{ - public UserKey UserKey { get; init; } - public TenantKey Tenant { get; init; } - - public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; - - public DateTimeOffset? AuthenticatedAt { get; init; } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs index 45e4e04b..bb4a0ac8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -19,6 +19,8 @@ public sealed class SessionValidationResult public DeviceId? BoundDeviceId { get; init; } + public DateTimeOffset? AuthenticatedAt { get; init; } + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; public bool IsValid => State == SessionState.Active; @@ -32,6 +34,7 @@ public static SessionValidationResult Active( SessionChainId chainId, SessionRootId rootId, ClaimsSnapshot claims, + DateTimeOffset authenticatedAt, DeviceId? boundDeviceId = null) => new() { @@ -42,6 +45,7 @@ public static SessionValidationResult Active( ChainId = chainId, RootId = rootId, Claims = claims, + AuthenticatedAt = authenticatedAt, BoundDeviceId = boundDeviceId }; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index ec3cdcfd..b978f3d4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -1,6 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; +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 { public string Value { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index 6d497ba9..99ff48b2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -3,6 +3,16 @@ 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; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs index d2edc1d3..c733e991 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs @@ -1,5 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; +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) { public static SessionChainId New() => new(Guid.NewGuid()); diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs index be7c1513..60a85cfb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs @@ -1,5 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; +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) { public static SessionRootId New() => new(Guid.NewGuid()); diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs index ffc2f0cb..51485596 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using System.Security.Claims; namespace CodeBeam.UltimateAuth.Core.Extensions; @@ -19,25 +20,19 @@ public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, st return new ClaimsPrincipal(identity); } - public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, UserKey? userKey, string authenticationType) + public static ClaimsPrincipal ToClaimsPrincipal(this AuthStateSnapshot snapshot, string authenticationType) { - if (snapshot == null) - return new ClaimsPrincipal(new ClaimsIdentity()); + var claims = snapshot.Claims.ToClaims().ToList(); - var claims = snapshot.Claims.SelectMany(kv => kv.Value.Select(v => new Claim(kv.Key, v))).ToList(); + claims.Add(new Claim(ClaimTypes.NameIdentifier, snapshot.Identity.UserKey.Value)); - if (userKey is not null) - { - var value = userKey.Value.ToString(); - claims.Add(new Claim(ClaimTypes.Name, value)); - claims.Add(new Claim(ClaimTypes.NameIdentifier, value)); - } + if (!string.IsNullOrWhiteSpace(snapshot.Identity.PrimaryUserName)) + claims.Add(new Claim(ClaimTypes.Name, snapshot.Identity.PrimaryUserName)); - var identity = new ClaimsIdentity(claims, authenticationType, ClaimTypes.Name, ClaimTypes.Role); - return new ClaimsPrincipal(identity); + var ci = new ClaimsIdentity(claims, authenticationType, ClaimTypes.Name, ClaimTypes.Role); + return new ClaimsPrincipal(ci); } - /// /// Converts an ASP.NET Core ClaimsPrincipal into a ClaimsSnapshot. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthSessionIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthSessionIdJsonConverter.cs new file mode 100644 index 00000000..e59512b6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/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/Authority/DeviceMismatchPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs deleted file mode 100644 index 5de4ac5e..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs +++ /dev/null @@ -1,30 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Infrastructure; - -public sealed class DeviceMismatchPolicy : IAuthorityPolicy -{ - public bool AppliesTo(AuthContext context) => context.Device is not null; - - public AccessDecisionResult Decide(AuthContext context) - { - var device = context.Device; - - //if (device.IsKnownDevice) - // return AuthorizationResult.Allow(); - - return context.Operation switch - { - AuthOperation.Access => - AccessDecisionResult.Deny("Access from unknown device."), - - AuthOperation.Refresh => - AccessDecisionResult.Challenge("Device verification required."), - - AuthOperation.Login => AccessDecisionResult.Allow(), // login establishes device - - _ => AccessDecisionResult.Allow() - }; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs deleted file mode 100644 index d4ac9b7e..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs +++ /dev/null @@ -1,38 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Infrastructure; - -public sealed class AuthModeOperationPolicy : IAuthorityPolicy -{ - public bool AppliesTo(AuthContext context) => true; // Applies to all contexts - - public AccessDecisionResult Decide(AuthContext context) - { - return context.Mode switch - { - UAuthMode.PureOpaque => DecideForPureOpaque(context), - UAuthMode.PureJwt => DecideForPureJwt(context), - UAuthMode.Hybrid => AccessDecisionResult.Allow(), - UAuthMode.SemiHybrid => AccessDecisionResult.Allow(), - - _ => AccessDecisionResult.Deny("Unsupported authentication mode.") - }; - } - - private static AccessDecisionResult DecideForPureOpaque(AuthContext context) - { - if (context.Operation == AuthOperation.Refresh) - return AccessDecisionResult.Deny("Refresh operation is not supported in PureOpaque mode."); - - return AccessDecisionResult.Allow(); - } - - private static AccessDecisionResult DecideForPureJwt(AuthContext context) - { - if (context.Operation == AuthOperation.Access) - return AccessDecisionResult.Deny("Session-based access is not supported in PureJwt mode."); - - return AccessDecisionResult.Allow(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs new file mode 100644 index 00000000..ae49bcda --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/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/SessionRootIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionRootIdJsonConverter.cs new file mode 100644 index 00000000..24c3c08f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/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/TenantKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/TenantKeyJsonConverter.cs new file mode 100644 index 00000000..b0d42356 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/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/MultiTenancy/TenantKey.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs index 4a8ca986..949dc694 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs @@ -1,8 +1,11 @@ -using System.Security; +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; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs new file mode 100644 index 00000000..88122509 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs @@ -0,0 +1,42 @@ +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 _identifierProvider; + + public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvider) + { + _identifierProvider = identifierProvider; + } + + public async Task CreateAsync(SessionValidationResult validation, CancellationToken ct = default) + { + if (!validation.IsValid || validation.UserKey is null) + return null; + + var identifiers = await _identifierProvider.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 = identifiers?.DisplayName, + AuthenticatedAt = validation.AuthenticatedAt + }; + + 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 index 44888e2a..6cbb1fad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs @@ -1,21 +1,19 @@ -using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class ClientProfileReader : IClientProfileReader { - private const string HeaderName = "X-UAuth-ClientProfile"; - private const string FormFieldName = "__uauth_client_profile"; - public UAuthClientProfile Read(HttpContext context) { - if (context.Request.Headers.TryGetValue(HeaderName, out var headerValue) && TryParse(headerValue, out var headerProfile)) + if (context.Request.Headers.TryGetValue(UAuthConstants.Headers.ClientProfile, out var headerValue) && TryParse(headerValue, out var headerProfile)) { return headerProfile; } - if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(FormFieldName, out var formValue) && + if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.ClientProfile, out var formValue) && TryParse(formValue, out var formProfile)) { return formProfile; @@ -28,5 +26,4 @@ private static bool TryParse(string? value, out UAuthClientProfile profile) { return Enum.TryParse(value, ignoreCase: true, out profile); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs index a2fcc734..59463779 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs @@ -5,9 +5,12 @@ namespace CodeBeam.UltimateAuth.Server.Authentication; public static class UAuthAuthenticationExtensions { - public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder builder) + public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder builder, Action? configure = null) { - return builder.AddScheme(UAuthCookieDefaults.AuthenticationScheme, - _ => { }); + return builder.AddScheme(UAuthSchemeDefaults.AuthenticationScheme, + options => + { + configure?.Invoke(options); + }); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs index 98add804..4072266a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -1,39 +1,49 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Server.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 +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, + 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 = _transportCredentialResolver.Resolve(Context); @@ -58,7 +68,67 @@ protected override async Task HandleAuthenticateAsync() if (!result.IsValid || result.UserKey is null) return AuthenticateResult.NoResult(); - var principal = result.Claims.ToClaimsPrincipal(result.UserKey, UAuthCookieDefaults.AuthenticationScheme); - return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthCookieDefaults.AuthenticationScheme)); + var snapshot = await _snapshotFactory.CreateAsync(result); + + if (snapshot is null || snapshot.Identity is null) + return AuthenticateResult.NoResult(); + + var principal = snapshot.ToClaimsPrincipal(UAuthSchemeDefaults.AuthenticationScheme); + return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthSchemeDefaults.AuthenticationScheme)); + } + + 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/UAuthAuthenticationCookieOptions.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs similarity index 65% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationCookieOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs index ae0e1b32..9f031e48 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationCookieOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Authentication; -public sealed class UAuthAuthenticationCookieOptions : AuthenticationSchemeOptions +public sealed class UAuthAuthenticationSchemeOptions : AuthenticationSchemeOptions { // TODO: - // - CookieName override // - Claim mapping // - Diagnostics flag } diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index a8340356..6f06bab2 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthCookieDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs similarity index 75% rename from src/CodeBeam.UltimateAuth.Server/Defaults/UAuthCookieDefaults.cs rename to src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs index 3f745cf0..bfcea56c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthCookieDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Defaults; -public static class UAuthCookieDefaults +public static class UAuthSchemeDefaults { public const string AuthenticationScheme = "UltimateAuth"; } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index 4f97e151..ac7fa6aa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -86,5 +86,4 @@ private void WriteRefreshHeader(HttpContext ctx, AuthFlowContext flow, RefreshOu _refreshWriter.Write(ctx, outcome); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index d76a8064..2495f81f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -13,17 +13,20 @@ internal sealed class ValidateEndpointHandler : IValidateEndpointHandler private readonly IAuthFlowContextAccessor _authContext; private readonly IFlowCredentialResolver _credentialResolver; private readonly ISessionValidator _sessionValidator; + private readonly IAuthStateSnapshotFactory _snapshotFactory; private readonly IClock _clock; public ValidateEndpointHandler( IAuthFlowContextAccessor authContext, IFlowCredentialResolver credentialResolver, ISessionValidator sessionValidator, + IAuthStateSnapshotFactory snapshotFactory, IClock clock) { _authContext = authContext; _credentialResolver = credentialResolver; _sessionValidator = sessionValidator; + _snapshotFactory = snapshotFactory; _clock = clock; } @@ -82,17 +85,13 @@ public async Task ValidateAsync(HttpContext context, CancellationToken ); } + var snapshot = await _snapshotFactory.CreateAsync(result); + return Results.Ok(new AuthValidationResult { IsValid = result.IsValid, State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant(), - Snapshot = new AuthStateSnapshot - { - UserKey = userKey, - Tenant = result.Tenant, - Claims = result.Claims, - AuthenticatedAt = _clock.UtcNow, - } + Snapshot = snapshot }); } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs index c4023a5e..deb51630 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs @@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; public static class EndpointRouteBuilderExtensions { - public static IEndpointRouteBuilder MapUAuthEndpoints(this IEndpointRouteBuilder endpoints) + public static IEndpointRouteBuilder MapUltimateAuthEndpoints(this IEndpointRouteBuilder endpoints) { var registrar = endpoints.ServiceProvider.GetRequiredService(); var options = endpoints.ServiceProvider.GetRequiredService>().Value; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs index 175864d5..d664f3a1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Constants; +using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -6,12 +7,12 @@ internal static class HttpContextReturnUrlExtensions { public static string? GetReturnUrl(this HttpContext ctx) { - if (ctx.Request.HasFormContentType && ctx.Request.Form.TryGetValue("return_url", out var form)) + if (ctx.Request.HasFormContentType && ctx.Request.Form.TryGetValue(UAuthConstants.Form.ReturnUrl, out var form)) { return form.ToString(); } - if (ctx.Request.Query.TryGetValue("return_url", out var query)) + if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var query)) { return query.ToString(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs index 51641fd5..08338fc3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Contracts; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -8,7 +8,7 @@ public static class HttpContextSessionExtensions { public static SessionContext GetSessionContext(this HttpContext context) { - if (context.Items.TryGetValue(SessionContextItemKeys.SessionContext, out var value) && value is SessionContext session) + if (context.Items.TryGetValue(UAuthConstants.HttpItems.SessionContext, out var value) && value is SessionContext session) { return session; } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs index bd1c749d..d1b57a00 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Server.Middlewares; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -8,7 +8,7 @@ public static class HttpContextTenantExtensions { public static TenantKey GetTenant(this HttpContext context) { - if (!context.Items.TryGetValue(TenantMiddleware.TenantContextKey, out var value) || value is not UAuthTenantContext tenantCtx) + 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."); } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs index 33dbd730..9936b9e6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs @@ -1,6 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -9,7 +9,7 @@ public static class HttpContextUserExtensions { public static AuthUserSnapshot GetUserContext(this HttpContext ctx) { - if (ctx.Items.TryGetValue(UserMiddleware.UserContextKey, out var value) && value is AuthUserSnapshot user) + if (ctx.Items.TryGetValue(UAuthConstants.HttpItems.UserContextKey, out var value) && value is AuthUserSnapshot user) { return user; } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 7a37e3a5..6b35ea70 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -15,6 +15,8 @@ using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Authentication; +using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -23,6 +25,8 @@ using CodeBeam.UltimateAuth.Server.Runtime; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Users; +using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -136,7 +140,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); @@ -158,6 +162,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.AddSingleton(); services.AddSingleton(); @@ -227,6 +232,23 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped>(); services.TryAddScoped(); + // ------------------------------ + // ASP.NET CORE INTEGRATION + // ------------------------------ + services.AddAuthentication(); + + services.PostConfigureAll(options => + { + options.DefaultAuthenticateScheme ??= UAuthSchemeDefaults.AuthenticationScheme; + options.DefaultSignInScheme ??= UAuthSchemeDefaults.AuthenticationScheme; + options.DefaultChallengeScheme ??= UAuthSchemeDefaults.AuthenticationScheme; + }); + + services.AddAuthentication().AddUAuthCookies(); + + + services.AddAuthorization(); + return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index 5d3cf175..7c011bbd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; public static class UltimateAuthApplicationBuilderExtensions { - public static IApplicationBuilder UseUltimateAuthServer(this IApplicationBuilder app) + public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app) { app.UseMiddleware(); app.UseMiddleware(); @@ -13,4 +13,12 @@ public static IApplicationBuilder UseUltimateAuthServer(this IApplicationBuilder return app; } + + public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder app) + { + app.UseUltimateAuth(); + app.UseAuthentication(); + app.UseAuthorization(); + return app; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs new file mode 100644 index 00000000..22d91d86 --- /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 AddUltimateAuthClientRoutes(this RazorComponentsEndpointConventionBuilder builder,Assembly clientAssembly) + { + return builder.AddAdditionalAssemblies(clientAssembly); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs index caf1f95e..1293f243 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -19,7 +20,7 @@ public void Write(HttpContext context, RefreshOutcome outcome) if (!_diagnostics.EnableRefreshHeaders) return; - context.Response.Headers["X-UAuth-Refresh"] = outcome switch + context.Response.Headers[UAuthConstants.Headers.Refresh] = outcome switch { RefreshOutcome.NoOp => "no-op", RefreshOutcome.Touched => "touched", diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs index 682fd36f..3ac067a1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstractions; using Microsoft.AspNetCore.Http; @@ -30,7 +31,7 @@ public DeviceInfo Resolve(HttpContext context) if (context.Request.Headers.TryGetValue("X-UDID", out var header)) return header.ToString(); - if (context.Request.HasFormContentType && context.Request.Form.TryGetValue("__uauth_device", out var formValue) && !StringValues.IsNullOrEmpty(formValue)) + if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.Device, out var formValue) && !StringValues.IsNullOrEmpty(formValue)) { return formValue.ToString(); } @@ -53,5 +54,4 @@ public DeviceInfo Resolve(HttpContext context) return "web"; } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs index 434bcb52..4c6764dc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Contracts; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Infrastructure; @@ -20,7 +21,7 @@ public SessionContext? Current if (ctx is null) return null; - if (ctx.Items.TryGetValue(SessionContextItemKeys.SessionContext, out var value)) + if (ctx.Items.TryGetValue(UAuthConstants.HttpItems.SessionContext, out var value)) return value as SessionContext; return null; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs deleted file mode 100644 index 38e1379e..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextItemKeys.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -internal static class SessionContextItemKeys -{ - public const string SessionContext = "__UAuth.SessionContext"; -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs index cf780253..6b933a91 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Middlewares; @@ -19,5 +20,5 @@ public HttpContextCurrentUser(IHttpContextAccessor http) public UserKey UserKey => Snapshot?.UserId ?? throw new InvalidOperationException("Current user is not authenticated."); - private AuthUserSnapshot? Snapshot => _http.HttpContext?.Items[UserMiddleware.UserContextKey] as AuthUserSnapshot; + private AuthUserSnapshot? Snapshot => _http.HttpContext?.Items[UAuthConstants.HttpItems.UserContextKey] as AuthUserSnapshot; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs index 9a7098a0..191aadf7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Extensions; @@ -24,7 +25,7 @@ public async Task ResolveAsync(HttpContext context) if (sessionCtx.IsAnonymous || sessionCtx.SessionId is null) { - context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Anonymous(); + context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Anonymous(); return; } @@ -38,11 +39,11 @@ public async Task ResolveAsync(HttpContext context) if (session is null || session.IsRevoked) { - context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Anonymous(); + context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Anonymous(); return; } var userId = _userIdConverter.FromString(session.UserKey.Value); - context.Items[UserMiddleware.UserContextKey] = AuthUserSnapshot.Authenticated(userId); + context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Authenticated(userId); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index 51d3eb3e..13929078 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; @@ -26,7 +27,7 @@ public async Task InvokeAsync(HttpContext context) ? SessionContext.Anonymous() : SessionContext.FromSessionId(sessionId.Value, tenant); - context.Items[SessionContextItemKeys.SessionContext] = sessionContext; + context.Items[UAuthConstants.HttpItems.SessionContext] = sessionContext; await _next(context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index e34c23d0..fef8b200 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.MultiTenancy; using Microsoft.AspNetCore.Http; @@ -9,7 +10,6 @@ namespace CodeBeam.UltimateAuth.Server.Middlewares; public sealed class TenantMiddleware { private readonly RequestDelegate _next; - public const string TenantContextKey = "__UAuthTenant"; public TenantMiddleware(RequestDelegate next) { @@ -23,7 +23,7 @@ public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOp if (!opts.Enabled) { - context.Items[TenantContextKey] = UAuthTenantContext.SingleTenant(); + context.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.SingleTenant(); await _next(context); return; } @@ -46,7 +46,7 @@ public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOp var tenantContext = UAuthTenantContext.Resolved(resolution.Tenant); - context.Items[TenantContextKey] = tenantContext; + 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 index 46e9b6c2..45904c73 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs @@ -8,8 +8,6 @@ public sealed class UserMiddleware { private readonly RequestDelegate _next; - public const string UserContextKey = "__UAuthUser"; - public UserMiddleware(RequestDelegate next) { _next = next; 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/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index c8e03cb7..ed587649 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -89,6 +89,9 @@ public sealed class UAuthServerOptions public UAuthUserIdentifierOptions UserIdentifiers { get; set; } = new(); + public UAuthNavigationOptions Navigation { get; set; } = new(); + + ///// ///// If true, server will add anti-forgery headers ///// and require proper request metadata. @@ -137,6 +140,7 @@ internal UAuthServerOptions Clone() SessionResolution = SessionResolution.Clone(), UserIdentifiers = UserIdentifiers.Clone(), Endpoints = Endpoints.Clone(), + Navigation = Navigation.Clone(), //EnableAntiCsrfProtection = EnableAntiCsrfProtection, //EnableLoginRateLimiting = EnableLoginRateLimiting, 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/UAuthServerProductInfo.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs index 1919ec0a..740c7b66 100644 --- a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs @@ -1,18 +1,17 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Runtime; -using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Options; namespace CodeBeam.UltimateAuth.Server.Runtime; public sealed class UAuthServerProductInfo { - public string ProductName { get; init; } = "UltimateAuthServer"; - public UAuthProductInfo Core { get; init; } = default!; + public string ProductName { get; init; } = "UltimateAuth Server"; + public string Version { get; init; } = default!; + public string? InformationalVersion { get; init; } - public UAuthMode? AuthMode { get; init; } - public UAuthHubDeploymentMode HubDeploymentMode { get; init; } + public DateTimeOffset StartedAt { get; init; } + public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); - public bool PkceEnabled { get; init; } - public bool RefreshEnabled { get; init; } + 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/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index f9ab8aa0..e8a51f72 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -53,6 +53,6 @@ public async Task ValidateSessionAsync(SessionValidatio // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); var claims = await _claimsProvider.GetClaimsAsync(context.Tenant, session.UserKey, ct); - return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, boundDeviceId: session.Device.DeviceId); + return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, session.Device.DeviceId); } } 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 index e03d7456..ce41f1eb 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj @@ -4,8 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 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 index c80fec61..e8e7f49f 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj @@ -4,8 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs index da4efad1..7bc3b7ac 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs @@ -22,5 +22,9 @@ public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { var adminKey = _ids.GetAdminUserId(); await _roles.AssignAsync(tenant, adminKey, "Admin", ct); + await _roles.AssignAsync(tenant, adminKey, "User", ct); + + var userKey = _ids.GetUserUserId(); + await _roles.AssignAsync(tenant, userKey, "User", ct); } } 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 index f42a8e77..345a98ba 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj @@ -4,8 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs index 99fbd3a5..eb58589c 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs @@ -23,6 +23,8 @@ public async Task GetClaimsAsync(TenantKey tenant, UserKey userK var builder = ClaimsSnapshot.Create(); + builder.Add("uauth:tenant", tenant.Value); + foreach (var role in roles) builder.Add(ClaimTypes.Role, role); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj index e03d7456..ce41f1eb 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -4,8 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 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 index e2fbe39d..ce41f1eb 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 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 index fcba3cc0..59c525b9 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 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 index 944f0a0a..9229218f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 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 index 6b2038f0..20c61cec 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index 466afb4c..731fb046 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Users.Abstractions; +using CodeBeam.UltimateAuth.Users; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index da6165c6..992ef573 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -3,7 +3,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Users.Abstractions; +using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Credentials.Reference; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj index ed91b1ae..12ee515c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj index e03d7456..ce41f1eb 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -4,8 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 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 index f29fc2e2..95281024 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 enable enable - 0.0.1-preview + 0.0.1 + 0.0.1 true $(NoWarn);1591 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.InMemory/Infrastructure/InMemoryUserIdProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs index 7f5c452f..74a3a233 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs @@ -5,8 +5,8 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserIdProvider : IInMemoryUserIdProvider { - private static readonly UserKey Admin = UserKey.FromString("admin"); - private static readonly UserKey User = UserKey.FromString("user"); + private static readonly UserKey Admin = UserKey.FromGuid(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + private static readonly UserKey User = UserKey.FromGuid(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")); public UserKey GetAdminUserId() => Admin; public UserKey GetUserUserId() => User; diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index 61e694c0..6af8bcb0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -33,11 +33,12 @@ public InMemoryUserSeedContributor( public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { - await SeedUserAsync(tenant, _ids.GetAdminUserId(), "Administrator", "admin", ct); - await SeedUserAsync(tenant, _ids.GetUserUserId(), "User", "user", ct); + await SeedUserAsync(tenant, _ids.GetAdminUserId(), "Administrator", "admin", "admin@ultimateauth.com", "1234567890", ct); + await SeedUserAsync(tenant, _ids.GetUserUserId(), "User", "user", "user@ultimateauth.com", "9876543210", ct); } - private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string username, CancellationToken ct) + private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string primaryUsername, + string primaryEmail, string primaryPhone, CancellationToken ct) { if (await _lifecycle.ExistsAsync(tenant, userKey, ct)) return; @@ -45,6 +46,7 @@ private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displ await _lifecycle.CreateAsync(tenant, new UserLifecycle { + Tenant = tenant, UserKey = userKey, Status = UserStatus.Active, CreatedAt = _clock.UtcNow @@ -53,6 +55,7 @@ await _lifecycle.CreateAsync(tenant, await _profiles.CreateAsync(tenant, new UserProfile { + Tenant = tenant, UserKey = userKey, DisplayName = displayName, CreatedAt = _clock.UtcNow @@ -61,9 +64,34 @@ await _profiles.CreateAsync(tenant, await _identifiers.CreateAsync(tenant, new UserIdentifier { + Tenant = tenant, UserKey = userKey, Type = UserIdentifierType.Username, - Value = username, + Value = primaryUsername, + IsPrimary = true, + IsVerified = true, + CreatedAt = _clock.UtcNow + }, ct); + + await _identifiers.CreateAsync(tenant, + new UserIdentifier + { + Tenant = tenant, + UserKey = userKey, + Type = UserIdentifierType.Email, + Value = primaryEmail, + IsPrimary = true, + IsVerified = true, + CreatedAt = _clock.UtcNow + }, ct); + + await _identifiers.CreateAsync(tenant, + new UserIdentifier + { + Tenant = tenant, + UserKey = userKey, + Type = UserIdentifierType.Phone, + Value = primaryPhone, IsPrimary = true, IsVerified = true, CreatedAt = _clock.UtcNow diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index abc82331..7818aa20 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -17,6 +17,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); return services; } 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..74dc264e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.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 PrimaryUserIdentifierProvider : IPrimaryUserIdentifierProvider +{ + private readonly IUserIdentifierStore _store; + + public PrimaryUserIdentifierProvider(IUserIdentifierStore store) + { + _store = store; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var identifiers = await _store.GetByUserAsync(tenant, 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/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 458344b6..22463285 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -4,7 +4,6 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Users.Abstractions; using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.Extensions.Options; 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/IUserLifecycleIntegration.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs index 068a54d2..c07c0ac8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs @@ -2,7 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Users.Abstractions; +namespace CodeBeam.UltimateAuth.Users; /// /// Optional integration point for reacting to user lifecycle events. 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..74e56fb3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs @@ -0,0 +1,53 @@ +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; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class AuthStateSnapshotFactoryTests +{ + [Fact] + public async Task CreateAsync_should_return_snapshot_when_valid() + { + var provider = new Mock(); + + provider.Setup(x => x.GetAsync(It.IsAny(), It.IsAny(), default)) + .ReturnsAsync(new PrimaryUserIdentifiers + { + UserName = "admin" + }); + + var factory = new AuthStateSnapshotFactory(provider.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 factory = new AuthStateSnapshotFactory(provider.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/BlazorServerSessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs deleted file mode 100644 index 1fdc364e..00000000 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/BlazorServerSessionCoordinatorTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Client.Diagnostics; -using CodeBeam.UltimateAuth.Client.Infrastructure; -using CodeBeam.UltimateAuth.Client.Options; -using CodeBeam.UltimateAuth.Client.Services; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Tests.Unit; - -public sealed class BlazorServerSessionCoordinatorTests -{ - //[Fact] - //public async Task StartAsync_MarksStarted_AndAutomaticRefresh() - //{ - // var diagnostics = new UAuthClientDiagnostics(); - // var client = new FakeFlowClient(RefreshOutcome.NoOp); - // var nav = new TestNavigationManager(); - - // var options = Options.Create(new UAuthClientOptions - // { - // Refresh = { Interval = TimeSpan.FromMilliseconds(10) } - // }); - - // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), - // nav, - // options, - // diagnostics); - - // await coordinator.StartAsync(); - // await Task.Delay(30); - // await coordinator.StopAsync(); - - // Assert.Equal(1, diagnostics.StartCount); - // Assert.True(diagnostics.AutomaticRefreshCount >= 1); - //} - - //[Fact] - //public async Task ReauthRequired_ShouldTerminateAndNavigate() - //{ - // var diagnostics = new UAuthClientDiagnostics(); - // var client = new FakeFlowClient(RefreshOutcome.ReauthRequired); - // var nav = new TestNavigationManager(); - - // var options = Options.Create(new UAuthClientOptions - // { - // Refresh = { Interval = TimeSpan.FromMilliseconds(5) }, - // Reauth = - // { - // Behavior = ReauthBehavior.RedirectToLogin, - // LoginPath = "/login" - // } - // }); - - // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), - // nav, - // options, - // diagnostics); - - // await coordinator.StartAsync(); - // await Task.Delay(20); - - // Assert.True(diagnostics.IsTerminated); - // Assert.Equal(CoordinatorTerminationReason.ReauthRequired, diagnostics.TerminationReason); - // Assert.Equal("/login", nav.LastNavigatedTo); - //} - - //[Fact] - //public async Task StopAsync_ShouldMarkStopped() - //{ - // var diagnostics = new UAuthClientDiagnostics(); - // var client = new FakeFlowClient(); - // var nav = new TestNavigationManager(); - - // var options = Options.Create(new UAuthClientOptions()); - - // var coordinator = new BlazorServerSessionCoordinator(new UAuthClient(client), - // nav, - // options, - // diagnostics); - - // await coordinator.StartAsync(); - // await coordinator.StopAsync(); - - // Assert.Equal(1, diagnostics.StopCount); - //} -} 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..f8302b0d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs @@ -0,0 +1,72 @@ +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Contracts; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; +using Moq; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class SessionCoordinatorTests +{ + [Fact] + public async Task StartAsync_should_not_start_when_auto_refresh_disabled() + { + 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); + await coordinator.StartAsync(); + + Assert.False(diagnostics.IsRunning); + } + + [Fact] + public async Task ReauthRequired_should_raise_event() + { + 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.FromMilliseconds(10) + }, + Reauth = new UAuthClientReauthOptions + { + Behavior = ReauthBehavior.RaiseEvent + } + }); + + var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics); + var triggered = false; + coordinator.ReauthRequired += () => triggered = true; + + await coordinator.StartAsync(); + await Task.Delay(50); + + Assert.True(triggered); + Assert.True(diagnostics.IsTerminated); + } +} 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/UAuthStateManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs new file mode 100644 index 00000000..cb3ee496 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs @@ -0,0 +1,89 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Authentication; +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 + { + IsValid = true, + Snapshot = snapshot + }); + + var client = new Mock(); + client.Setup(x => x.Flows).Returns(flowClient.Object); + + var clock = new Mock(); + clock.Setup(x => x.UtcNow).Returns(DateTimeOffset.UtcNow); + + var manager = new UAuthStateManager(client.Object, clock.Object); + + await manager.EnsureAsync(); + await manager.EnsureAsync(); + + flowClient.Verify(x => x.ValidateAsync(), Times.Once); + } + + [Fact] + public async Task EnsureAsync_force_should_always_validate() + { + var client = new Mock(); + var clock = new Mock(); + + client.Setup(x => x.Flows.ValidateAsync()) + .ReturnsAsync(new AuthValidationResult + { + IsValid = false + }); + + var manager = new UAuthStateManager(client.Object, clock.Object); + + await manager.EnsureAsync(force: true); + await manager.EnsureAsync(force: true); + + client.Verify(x => x.Flows.ValidateAsync(), Times.Exactly(2)); + } + + [Fact] + public async Task EnsureAsync_invalid_should_clear_state() + { + var client = new Mock(); + var clock = new Mock(); + + client.Setup(x => x.Flows.ValidateAsync()) + .ReturnsAsync(new AuthValidationResult + { + IsValid = false + }); + + var manager = new UAuthStateManager(client.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/Helpers/TestHttpContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs index e9c83b01..ef3d3deb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs @@ -1,6 +1,6 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -12,7 +12,7 @@ public static HttpContext Create(TenantKey? tenant = null, UAuthClientProfile cl var ctx = new DefaultHttpContext(); var resolvedTenant = tenant ?? TenantKey.Single; - ctx.Items[TenantMiddleware.TenantContextKey] = UAuthTenantContext.Resolved(resolvedTenant); + ctx.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.Resolved(resolvedTenant); ctx.Request.Headers["User-Agent"] = "UltimateAuth-Test"; ctx.Request.Scheme = "https"; 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/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index b237d8fb..5843e160 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -73,7 +73,7 @@ await orchestrator.LoginAsync(flow, var store = runtime.Services.GetRequiredService>(); - var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + var state = store.GetState(TenantKey.Single, TestUsers.User); state!.FailedLoginAttempts.Should().Be(1); } @@ -109,7 +109,7 @@ await orchestrator.LoginAsync(flow, var store = runtime.Services.GetRequiredService>(); - var state = store.GetState(TenantKey.Single,UserKey.Parse("user", null)); + var state = store.GetState(TenantKey.Single, TestUsers.User); state.Should().BeNull(); } @@ -172,7 +172,7 @@ await orchestrator.LoginAsync(flow, }); var store = runtime.Services.GetRequiredService>(); - var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + var state = store.GetState(TenantKey.Single, TestUsers.User); state!.IsLocked.Should().BeTrue(); } @@ -232,7 +232,7 @@ await orchestrator.LoginAsync(flow, }); var store = runtime.Services.GetRequiredService>(); - var state1 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + var state1 = store.GetState(TenantKey.Single, TestUsers.User); await orchestrator.LoginAsync(flow, new LoginRequest @@ -243,7 +243,7 @@ await orchestrator.LoginAsync(flow, Device = TestDevice.Default(), }); - var state2 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + var state2 = store.GetState(TenantKey.Single, TestUsers.User); state2!.FailedLoginAttempts.Should().Be(state1!.FailedLoginAttempts); } @@ -272,7 +272,7 @@ await orchestrator.LoginAsync(flow, } var store = runtime.Services.GetRequiredService>(); - var state = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + var state = store.GetState(TenantKey.Single, TestUsers.User); state!.IsLocked.Should().BeFalse(); state.FailedLoginAttempts.Should().Be(5); @@ -319,7 +319,7 @@ await orchestrator.LoginAsync(flow, }); var store = runtime.Services.GetRequiredService>(); - var state1 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + var state1 = store.GetState(TenantKey.Single, TestUsers.User); var lockedUntil = state1!.LockedUntil; @@ -332,14 +332,13 @@ await orchestrator.LoginAsync(flow, Device = TestDevice.Default(), }); - var state2 = store.GetState(TenantKey.Single, UserKey.Parse("user", null)); + var state2 = store.GetState(TenantKey.Single, TestUsers.User); state2!.LockedUntil.Should().Be(lockedUntil); } [Fact] public async Task Login_success_should_trigger_UserLoggedIn_event() { - // arrange UserLoggedInContext? captured = null; var runtime = new TestAuthRuntime(configureServer: o => @@ -354,7 +353,6 @@ public async Task Login_success_should_trigger_UserLoggedIn_event() var orchestrator = runtime.GetLoginOrchestrator(); var flow = await runtime.CreateLoginFlowAsync(); - // act await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, @@ -363,9 +361,8 @@ public async Task Login_success_should_trigger_UserLoggedIn_event() Device = TestDevice.Default() }); - // assert captured.Should().NotBeNull(); - captured!.UserKey.Should().Be(UserKey.Parse("user", null)); + captured!.UserKey.Should().Be(TestUsers.User); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs index 58bd4dcd..45703d8a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; @@ -47,7 +48,7 @@ public void ClientProfile_Is_Read_From_Header() { var reader = new ClientProfileReader(); var ctx = TestHttpContext.Create(); - ctx.Request.Headers["X-UAuth-ClientProfile"] = "BlazorServer"; + ctx.Request.Headers[UAuthConstants.Headers.ClientProfile] = "BlazorServer"; var profile = reader.Read(ctx); profile.Should().Be(UAuthClientProfile.BlazorServer); From edfcb21e52249d109b574b47bbe05b1ffbf16e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:41:57 +0300 Subject: [PATCH 32/50] Sample Improvement (Part 1/2) (#20) * Sample Improvement * Added UAuthStateView Component * Add UAuthScope component and UAuthReactiveComponentBase & Arrange Other Components * Enhanced Login Page * Fixed Login Failure Message Flow * Enhanced Login Lockout Client Handling * Complete Login Flow For Client & Login Sample Page * Fix Test * Login Page Last Improvement * Login Page Cleanup * Home Page Profile Section Design & Fixed DisplayName Doesn't Show in State & Sample DarkMode Implementation * Diagnostics Section Design & RefreshOutcome Improvement * Improve Validation Client Handling & Postpone Reauth Flow Until Adding MFA * Enhance Identifier Handling (Part 1) * Enhance Credential Plugin Domain --- ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 4 +- .../Components/Layout/MainLayout.razor | 6 - .../Components/Pages/Home.razor | 4 +- .../Components/Pages/Home.razor.cs | 1 - .../Components/Routes.razor | 6 + .../Program.cs | 22 +- .../Brand/UAuthLogo.razor | 33 ++ .../Brand/UAuthLogo.razor.cs | 54 ++ .../Brand/UAuthLogoVariant.cs | 7 + ...am.UltimateAuth.Sample.BlazorServer.csproj | 6 +- .../Components/App.razor | 3 +- .../Components/Dialogs/IdentifierDialog.razor | 221 ++++++++ .../Components/Layout/MainLayout.razor | 56 +- .../Components/Layout/MainLayout.razor.cs | 76 +++ .../Components/Layout/MainLayout.razor.css | 78 --- .../Components/Pages/AnonymousTestPage.razor | 1 + .../Components/Pages/Home.razor | 482 ++++++++++++++---- .../Components/Pages/Home.razor.cs | 168 +++++- .../Components/Pages/Login.razor | 167 ++++-- .../Components/Pages/Login.razor.cs | 193 ++++--- .../Components/Pages/NotAuthorized.razor | 27 + .../Components/Pages/NotAuthorized.razor.cs | 15 + .../Components/Routes.razor | 68 ++- .../Infrastructure/DarkModeManager.cs | 45 ++ .../Program.cs | 11 +- .../wwwroot/UltimateAuth-Logo.png | Bin 0 -> 14776 bytes .../wwwroot/app.css | 35 +- .../wwwroot/favicon.png | Bin 1148 -> 0 bytes .../App.razor | 11 +- ...ateAuth.Sample.BlazorStandaloneWasm.csproj | 6 +- .../Layout/MainLayout.razor | 94 +++- .../Pages/Home.razor | 2 +- .../Abstractions/IBrowserStorage.cs | 2 +- ...cs => UAuthAuthenticationStateProvider.cs} | 0 .../Authentication/UAuthState.cs | 1 + .../Authentication/UAuthStateManager.cs | 6 + .../Components/UAuthApp.razor | 14 +- .../Components/UAuthApp.razor.cs | 23 +- .../Components/UAuthFlowPageBase.cs | 104 ++++ .../Components/UAuthReactiveComponentBase.cs | 56 ++ .../Components/UAuthScope.razor | 4 + .../Components/UAuthScope.razor.cs | 10 + .../Components/UAuthStateView.razor | 35 ++ .../Components/UAuthStateView.razor.cs | 65 +++ .../Contracts/RefreshResult.cs | 2 +- .../Contracts/UAuthRenderMode.cs | 7 + .../Contracts/UAuthTransportResult.cs | 8 + .../Device/BrowserDeviceIdStorage.cs | 2 +- .../Diagnostics/UAuthClientDiagnostics.cs | 6 +- .../Errors/UAuthClientException.cs | 14 + .../Errors/UAuthProtocolException.cs | 12 + .../Errors/UAuthTransportException.cs | 26 + .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Infrastructure/BrowserStorage.cs | 2 +- .../Infrastructure/IUAuthRequestClient.cs | 2 - .../Infrastructure/RefreshOutcomeParser.cs | 5 +- .../Infrastructure/SessionCoordinator.cs | 2 +- .../Infrastructure/UAuthRequestClient.cs | 40 +- .../Infrastructure/UAuthResultMapper.cs | 70 ++- .../Services/IFlowClient.cs | 3 +- .../Services/UAuthAuthorizationClient.cs | 8 +- .../Services/UAuthCredentialClient.cs | 20 +- .../Services/UAuthFlowClient.cs | 77 ++- .../Services/UAuthUserClient.cs | 8 +- .../Services/UAuthUserIdentifierClient.cs | 30 +- .../TScripts/uauth.js | 12 +- .../wwwroot/uauth.min.js | 2 +- .../Contracts/Auth/AuthFlowPayload.cs | 22 + .../Contracts/Auth/AuthIdentitySnapshot.cs | 2 + .../Contracts/Common/UAuthProblem.cs | 13 + .../Contracts/Common/UAuthResult.cs | 23 +- .../Contracts/Login/LoginResult.cs | 8 +- .../Contracts/Session/AuthValidationResult.cs | 24 +- .../Domain/Principals/AuthFailureReason.cs | 1 + .../Domain/Session/RefreshOutcome.cs | 2 +- .../Domain/Session/SessionState.cs | 3 +- .../UAuthChallengeRequiredException.cs | 4 +- .../Base/UAuthAuthorizationException.cs | 4 +- .../Errors/Base/UAuthChainException.cs | 13 - .../Errors/Base/UAuthDeveloperException.cs | 15 +- .../Errors/Base/UAuthDomainException.cs | 15 +- .../Errors/Base/UAuthException.cs | 28 +- .../Errors/Base/UAuthRuntimeException.cs | 14 + .../Errors/Base/UAuthSessionException.cs | 27 - .../Errors/Developer/UAuthConfigException.cs | 17 - .../Developer/UAuthInternalException.cs | 19 - .../Errors/Developer/UAuthStoreException.cs | 17 - .../Runtime/UAuthIdentifierException.cs | 32 ++ .../Session/UAuthChainLinkMissingException.cs | 11 - .../UAuthSessionChainNotFoundException.cs | 11 - .../UAuthSessionChainRevokedException.cs | 11 - .../UAuthSessionDeviceMismatchException.cs | 16 - .../Session/UAuthSessionExpiredException.cs | 25 - .../UAuthSessionInvalidStateException.cs | 14 - .../Session/UAuthSessionNotActiveException.cs | 24 - .../Session/UAuthSessionNotFoundException.cs | 11 - .../Session/UAuthSessionRevokedException.cs | 26 - .../UAuthSessionRootRevokedException.cs | 12 - .../UAuthSessionSecurityMismatchException.cs | 18 - .../Errors/UAuthConflictException.cs | 8 + .../Errors/UAuthDeviceLimitException.cs | 24 - .../Errors/UAuthForbiddenException.cs | 8 + .../UAuthInvalidCredentialsException.cs | 17 - .../Errors/UAuthInvalidPkceCodeException.cs | 19 - .../Errors/UAuthNotFoundException.cs | 8 + .../Errors/UAuthRootRevokedException.cs | 19 - .../Errors/UAuthTokenTamperedException.cs | 25 - .../Errors/UAuthUnauthorizedException.cs | 8 + .../Errors/UAuthValidationException.cs | 8 + .../Infrastructure/UAuthUserIdConverter.cs | 5 +- .../Options/UAuthLoginOptions.cs | 21 +- .../Validators/UAuthLoginOptionsValidator.cs | 2 +- .../Auth/AuthStateSnapshotFactory.cs | 11 +- .../Response/EffectiveRedirectResponse.cs | 35 +- .../Bridges/LoginEndpointHandlerBridge.cs | 24 +- .../Bridges/LogoutEndpointHandlerBridge.cs | 26 +- .../Bridges/PkceEndpointHandlerBridge.cs | 26 +- .../Endpoints/LoginEndpointHandler.cs | 10 +- .../Endpoints/LogoutEndpointHandler.cs | 6 +- .../Endpoints/PkceEndpointHandler.cs | 6 +- .../Endpoints/RefreshEndpointHandler.cs | 23 +- .../Endpoints/ValidateEndpointHandler.cs | 15 +- .../Extensions/AuthFailureReasonExtensions.cs | 20 + .../Extensions/ServiceCollectionExtensions.cs | 59 +-- .../UAuthApplicationBuilderExtensions.cs | 1 + .../UAuthExceptionHandlingExtensions.cs | 56 ++ .../Flows/Login/ILoginOrchestrator.cs | 2 +- .../Flows/Login/LoginAuthority.cs | 19 +- .../Flows/Login/LoginDecision.cs | 15 +- .../Flows/Login/LoginOrchestrator.cs | 166 +++--- .../Flows/Refresh/RefreshResponseWriter.cs | 2 +- .../Orchestrator/CreateLoginSessionCommand.cs | 2 +- .../Redirect/AuthRedirectResolver.cs | 63 ++- .../Redirect/IAuthRedirectResolver.cs | 5 +- .../Options/LoginRedirectOptions.cs | 7 +- .../Options/UAuthDiagnosticsOptions.cs | 8 +- .../Options/UAuthLoginIdentifierOptions.cs | 28 + .../Options/UAuthServerOptions.cs | 3 + .../Options/UAuthUserIdentifierOptions.cs | 2 + .../UAuthServerLoginOptionsValidator.cs | 2 +- .../Runtime/UAuthServerProductInfo.cs | 2 +- .../Services/IUAuthFlowService.cs | 2 +- .../Services/UAuthFlowService.cs | 6 +- .../Services/UAuthSessionValidator.cs | 1 + .../Dtos/CredentialDto.cs | 5 +- .../Dtos/CredentialMetadata.cs | 1 - .../Dtos/CredentialSecurityState.cs | 105 +++- .../Dtos/CredentialSecurityStatus.cs | 1 - .../InMemoryCredentialSeedContributor.cs | 25 +- .../InMemoryCredentialStore.cs | 191 +++---- .../InMemoryPasswordCredentialState.cs | 8 +- .../ServiceCollectionExtensions.cs | 5 +- .../Domain/PasswordCredential.cs | 73 ++- .../PasswordCredentialFactory.cs | 19 + .../PasswordUserLifecycleIntegration.cs | 19 +- .../Services/UserCredentialsService.cs | 215 +++++--- .../Abstractions/ICredential.cs | 7 +- .../Abstractions/ICredentialSecretStore.cs | 9 - .../Abstractions/ICredentialStore.cs | 23 +- .../Abstractions/ICredentialValidator.cs | 2 +- .../Abstractions/ILoginCredential.cs | 6 - .../Abstractions/IPublicKeyCredential.cs | 2 +- .../Abstractions/ISecretCredential.cs | 2 +- .../Abstractions/ISecurableCredential.cs | 8 - .../Infrastructure/CredentialValidator.cs | 6 +- .../Dtos/UserIdentifierDto.cs | 11 +- .../Dtos/UserProfileSnapshot.cs | 8 + .../Requests/DeleteUserIdentifierRequest.cs | 5 +- .../SetPrimaryUserIdentifierRequest.cs | 3 +- .../UnsetPrimaryUserIdentifierRequest.cs | 3 +- .../Requests/UpdateUserIdentifierRequest.cs | 5 +- .../Requests/VerifyUserIdentifierRequest.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 7 +- .../InMemoryUserSecurityState.cs | 1 + .../InMemoryUserSecurityStateProvider.cs | 13 +- .../InMemoryUserSecurityStateWriter.cs | 28 +- .../InMemoryUserSeedContributor.cs | 5 +- .../Stores/InMemoryUserIdentifierStore.cs | 172 ++++--- .../Stores/InMemoryUserProfileStore.cs | 3 +- .../Stores/InMemoryUserSecurityStore.cs | 23 +- .../Domain/UserIdentifier.cs | 2 + .../Extensions/ServiceCollectonExtensions.cs | 3 +- .../Infrastructure/LoginIdentifierResolver.cs | 136 +++++ .../UserProfileSnapshotProvider.cs | 30 ++ .../Mapping/UserIdentifierMapper.cs | 1 + .../Services/UserApplicationService.cs | 100 ++-- .../Stores/IUserIdentifierStore.cs | 11 +- .../IUserProfileSnapshotProvider.cs | 10 + .../Abstractions/IUserSecurityState.cs | 1 + .../IUserSecurityStateDebugView.cs | 9 +- .../IUserSecurityStateProvider.cs | 7 +- .../Abstractions/IUserSecurityStateWriter.cs | 11 +- .../ICustomLoginIdentifierResolver.cs | 10 + .../ILoginIdentifierResolver.cs | 8 + .../LoginIdentifierResolution.cs | 17 + .../Client/AuthStateSnapshotFactoryTests.cs | 7 +- .../Client/ClientDiagnosticsTests.cs | 4 +- .../Client/RefreshOutcomeParserTests.cs | 2 +- .../Client/UAuthStateManagerTests.cs | 6 +- .../Core/OptionValidatorTests.cs | 4 +- .../Fake/FakeFlowClient.cs | 4 +- .../Helpers/TestAuthRuntime.cs | 8 +- .../Helpers/TestRedirectResolver.cs | 7 +- .../Server/LoginOrchestratorTests.cs | 14 +- .../Server/RedirectTests.cs | 57 ++- 205 files changed, 3655 insertions(+), 1724 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogoVariant.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AnonymousTestPage.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DarkModeManager.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/UltimateAuth-Logo.png delete mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/favicon.png rename src/CodeBeam.UltimateAuth.Client/Authentication/{UAuthAuthenticatonStateProvider.cs => UAuthAuthenticationStateProvider.cs} (100%) create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthReactiveComponentBase.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthFlowPayload.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthRuntimeException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthIdentifierException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthConflictException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthForbiddenException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthUnauthorizedException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthValidationException.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileSnapshot.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ICustomLoginIdentifierResolver.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ILoginIdentifierResolver.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Infrastructure/LoginIdentifierResolution.cs diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index a363c1bd..13ddab2b 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor index 74eaceb7..d257eb7a 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor @@ -2,12 +2,6 @@ @using CodeBeam.UltimateAuth.Server.Infrastructure @inherits LayoutComponentBase - - - - - - @Body
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index b1bff61d..3202e106 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -3,7 +3,7 @@ @using CodeBeam.UltimateAuth.Client @using CodeBeam.UltimateAuth.Client.Authentication @using CodeBeam.UltimateAuth.Client.Diagnostics -@using CodeBeam.UltimateAuth.Client.Utilities +@using CodeBeam.UltimateAuth.Client.Infrastructure @using CodeBeam.UltimateAuth.Core.Abstractions @using CodeBeam.UltimateAuth.Core.Contracts @using CodeBeam.UltimateAuth.Core.Domain @@ -17,7 +17,7 @@ @inject IHubCredentialResolver HubCredentialResolver @inject IAuthStore AuthStore @inject IBrowserStorage BrowserStorage -@inject IUAuthFlowService Flow +@inject IUAuthFlowService Flow @inject ISnackbar Snackbar @inject IFlowCredentialResolver CredentialResolver @inject IUAuthClient UAuthClient diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs index 5d81798e..31199c37 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Client.Utilities; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Stores; diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor index d5f439a2..f06c25ba 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor @@ -1,4 +1,10 @@  + + + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 1b7b6973..22f00b6f 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -30,21 +30,21 @@ builder.Services.AddMudServices(); builder.Services.AddMudExtensions(); -builder.Services - .AddAuthentication(options => - { - options.DefaultAuthenticateScheme = UAuthSchemeDefaults.AuthenticationScheme; - options.DefaultSignInScheme = UAuthSchemeDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = UAuthSchemeDefaults.AuthenticationScheme; - }) - .AddUAuthCookies(); +//builder.Services +// .AddAuthentication(options => +// { +// options.DefaultAuthenticateScheme = UAuthSchemeDefaults.AuthenticationScheme; +// options.DefaultSignInScheme = UAuthSchemeDefaults.AuthenticationScheme; +// options.DefaultChallengeScheme = UAuthSchemeDefaults.AuthenticationScheme; +// }) +// .AddUAuthCookies(); -builder.Services.AddAuthorization(); +//builder.Services.AddAuthorization(); -builder.Services.AddHttpContextAccessor(); +//builder.Services.AddHttpContextAccessor(); builder.Services.AddUltimateAuthServer(o => { - o.Diagnostics.EnableRefreshHeaders = true; + o.Diagnostics.EnableRefreshDetails = true; //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); 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..da3cf268 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor @@ -0,0 +1,33 @@ +@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 index 3b3f8455..34314a51 100644 --- 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 @@ -9,9 +9,9 @@ - - - + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor index b2e336d0..24f946d8 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor @@ -6,8 +6,7 @@ - - + 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..6aba7ba5 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -0,0 +1,221 @@ +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + Identifiers + + + + + + + + + + + + + + @if (context.Item.IsPrimary) + { + + + + } + else + { + + + + } + + + + + + + + + + + + + + + + + + + + + + + Add + + + + + + + + Cancel + OK + + + +@code { + private UserIdentifierType _newIdentifierType; + private string? _newIdentifierValue; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private List _identifiers = new(); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + var result = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + _identifiers = result.Value.ToList(); + StateHasChanged(); + } + } + } + + private async Task CommittedItemChanges(UserIdentifierDto item) + { + UpdateUserIdentifierRequest updateRequest = new() + { + Id = item.Id, + NewValue = item.Value + }; + var result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); + if (result.IsSuccess) + { + Snackbar.Add("Identifier updated successfully", Severity.Success); + } + else + { + Snackbar.Add("Failed to update identifier", Severity.Error); + } + + 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 + }; + + var result = await UAuthClient.Identifiers.AddSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Identifier added successfully", Severity.Success); + var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + _identifiers = getResult.Value?.ToList() ?? new List(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to add identifier", Severity.Error); + } + } + + private async Task SetPrimaryAsync(Guid id) + { + SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + var result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier set successfully", Severity.Success); + var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + _identifiers = getResult.Value?.ToList() ?? new List(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to set primary identifier", Severity.Error); + } + } + + private async Task VerifyAsync(Guid id) + { + VerifyUserIdentifierRequest request = new() { IdentifierId = id }; + var result = await UAuthClient.Identifiers.VerifySelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier verified successfully", Severity.Success); + var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + _identifiers = getResult.Value?.ToList() ?? new List(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to verify primary identifier", Severity.Error); + } + } + + private async Task UnsetPrimaryAsync(Guid id) + { + UnsetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + var result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier unset successfully", Severity.Success); + var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + _identifiers = getResult.Value?.ToList() ?? new List(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to unset primary identifier", Severity.Error); + } + } + + private async Task DeleteIdentifier(Guid id) + { + DeleteUserIdentifierRequest request = new() { IdentifierId = id }; + var result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Identifier deleted successfully", Severity.Success); + var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + _identifiers = getResult.Value?.ToList() ?? new List(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to delete identifier", Severity.Error); + } + } + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} 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 index 92e6700c..336f6537 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -1,7 +1,61 @@ @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 + +
-@Body
An unhandled error has occurred. 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..8ee2d479 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs @@ -0,0 +1,76 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +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 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 index 038baf17..df8c10ff 100644 --- 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 @@ -1,81 +1,3 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} - #blazor-error-ui { background: lightyellow; bottom: 0; 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/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 14f3284a..f1008c9d 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -1,109 +1,387 @@ @page "/home" -@using System.Security.Claims @attribute [Authorize] +@inherits UAuthFlowPageBase -@inject AuthenticationStateProvider AuthProvider @inject IUAuthClient UAuthClient @inject UAuthClientDiagnostics Diagnostics +@inject AuthenticationStateProvider AuthStateProvider +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using System.Security.Claims + + + + + + + + + @(AuthState?.Identity?.DisplayName?.Substring(0, 2)) + + + @AuthState?.Identity?.DisplayName + + @foreach (var role in AuthState.Claims.Roles) + { + + @role + + } + + + + + + + + + + @if (_selectedAuthState == "UAuthState") + { + + +
+ + + Tenant + + @AuthState?.Identity?.Tenant.Value +
+ +
+ + +
+ + + User Id + + @AuthState?.Identity?.UserKey.Value +
+
+ + +
+ + + Authenticated + + @(AuthState.IsAuthenticated ? "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 + + @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 +
+
+
+ } +
+
+
+ + + + + + Session + + + + + Validate + + + + + + Manual Refresh + + + + + + Logout + + + + + + + Account + + Manage Identifiers + + + + Change Password + + + + + + Admin + + + + + + + + @if (AuthState.IsInRole("Admin") || _showAdminPreview) + { + + + + Add User + + + + + Assign Role + + + + } + + @if (_showAdminPreview) + { + + Admin operations are shown for preview. Sign in as an Admin to execute them. + + } + + + + + + + @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 + @Diagnostics.RefreshTouchedCount + + + + + + No-Op + @Diagnostics.RefreshNoOpCount + + - - - - - - - @(AuthState?.Identity?.UserKey.Value.Substring(0, 2).ToUpper()) - - - - - ASP.NET Core State - IsAuthenticated: @_aspNetCoreState?.Identity?.IsAuthenticated - User Id: @_aspNetCoreState?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value - User Name: @_aspNetCoreState?.Identity?.Name - Authentication Type: @_aspNetCoreState?.Identity?.AuthenticationType - Is Admin: @_aspNetCoreState?.IsInRole("Admin") - - - - UAuth State - IsAuthenticated: @AuthState.IsAuthenticated - UserKey: @AuthState?.Identity?.UserKey.Value - User Name: @AuthState?.Identity?.PrimaryUserName - Primary Email: @AuthState?.Identity?.PrimaryEmail - Primary Phone: @AuthState?.Identity?.PrimaryPhone - Tenant: @AuthState?.Identity?.Tenant.Value - Authenticated At: @AuthState?.Identity?.AuthenticatedAt - Last Validated At: @AuthState?.LastValidatedAt - Is Admin: @AuthState.IsInRole("Admin") - Roles: @string.Join(", ", AuthState.Claims.Roles) - - - - - - - User Operations - - - - - - - Add User - - - - Assign Role - - - - - Change Password - - - - Manual Refresh - - - - Logout - - - - - - - - - UltimateAuth Client Diagnostics - - - - Started: @Diagnostics.StartCount - @Diagnostics.StartedAt - Stopped: @Diagnostics.StopCount - @Diagnostics.StoppedAt - Terminated: @Diagnostics.TerminatedCount - @Diagnostics.TerminatedAt (@Diagnostics.TerminationReason.ToString()) - - - - Refresh Attempts: @Diagnostics.RefreshAttemptCount - Auto: @Diagnostics.AutomaticRefreshCount - Manual: @Diagnostics.ManualRefreshCount - - - Touched Success: @Diagnostics.RefreshTouchedCount - - - No-Op Success: @Diagnostics.RefreshNoOpCount - - - ReauthRequired: @Diagnostics.RefreshReauthRequiredCount - - - Unknown: @Diagnostics.RefreshUnknownCount - - - - - + + + Reauth Required + @Diagnostics.RefreshReauthRequiredCount + + + + + + + +
+
\ No newline at end of file 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 index 1d314585..38a6f1cc 100644 --- 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 @@ -1,44 +1,184 @@ using CodeBeam.UltimateAuth.Client; -using Microsoft.AspNetCore.Components; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; using Microsoft.AspNetCore.Components.Authorization; +using MudBlazor; using System.Security.Claims; namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; -public partial class Home +public partial class Home : UAuthFlowPageBase { + private string _selectedAuthState = "UAuthState"; private ClaimsPrincipal? _aspNetCoreState; - [CascadingParameter] - public UAuthState AuthState { get; set; } = default!; - - [CascadingParameter] - Task AuthenticationStateTask { get; set; } = default!; + private bool _showAdminPreview = false; protected override async Task OnInitializedAsync() { - var state = await AuthenticationStateTask; - _aspNetCoreState = state.User; + 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 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) + { + 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; - private async Task RefreshSession() - => await UAuthClient.Flows.RefreshAsync(false); + 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 Task CreateUser() => Task.CompletedTask; private Task AssignRole() => Task.CompletedTask; private Task ChangePassword() => Task.CompletedTask; - public void Dispose() + 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 OpenIdentifierDialog() + { + + await DialogService.ShowAsync("Manage Identifiers", 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(); + AuthStateProvider.AuthenticationStateChanged -= OnAuthStateChanged; Diagnostics.Changed -= OnDiagnosticsChanged; } } 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 index 11ced661..1db4091f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -1,61 +1,118 @@ @page "/login" @attribute [UAuthLoginPage] +@inherits UAuthFlowPageBase -@inject IUAuthClient UAuth -@inject AuthenticationStateProvider AuthStateProvider +@implements IDisposable +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar -@inject NavigationManager Nav @inject IUAuthClientProductInfoProvider ClientProductInfoProvider @inject IDeviceIdProvider DeviceIdProvider -@inject UAuthClientDiagnostics Diagnostics - -
- - - - Welcome to UltimateAuth! - - - Login - - - - - Validate - Logout - Refresh - - - - Programmatic Login - GetMe - Change User Inactive - - - - @_productInfo?.ProductName v @_productInfo?.Version - Client Profile: @_productInfo?.ClientProfile.ToString() - Framework Description: @_productInfo?.FrameworkDescription - - - - State of Authentication: - @(_aspNetCoreState?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_aspNetCoreState?.Identity?.Name) - UAuthState @(AuthState.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(AuthState.Identity?.UserKey) - - - Authorized context is shown. @context?.User?.Identity?.IsAuthenticated - - - Not Authorized context is shown. - - - - - - This is Admin content. - - - - -
\ No newline at end of file + + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs index f5e81d06..757ca466 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -2,38 +2,79 @@ using CodeBeam.UltimateAuth.Client.Runtime; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; -using System.Security.Claims; namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; -public partial class Login +public partial class Login : UAuthFlowPageBase { private string? _username; private string? _password; - private ClaimsPrincipal? _aspNetCoreState; private UAuthClientProductInfo? _productInfo; - - [CascadingParameter] - public UAuthState AuthState { get; set; } = default!; - - [CascadingParameter] - Task AuthenticationStateTask { get; set; } = default!; + 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() { - var state = await AuthenticationStateTask; - _aspNetCoreState = state.User; - Diagnostics.Changed += OnDiagnosticsChanged; _productInfo = ClientProductInfoProvider.Get(); } - private void OnDiagnosticsChanged() + 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) { - InvokeAsync(StateHasChanged); + 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() @@ -45,93 +86,83 @@ private async Task ProgrammaticLogin() Secret = "admin", Device = DeviceContext.FromDeviceId(deviceId), }; - await UAuth.Flows.LoginAsync(request, "/home"); + await UAuthClient.Flows.LoginAsync(request, "/home"); } - private async Task ValidateAsync() + private async void StartCountdown() { - var result = await UAuth.Flows.ValidateAsync(); + if (_lockoutUntil is null) + return; - Snackbar.Add( - result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", - result.IsValid ? Severity.Success : Severity.Error); - } + _isLocked = true; + _lockoutStartedAt = DateTimeOffset.UtcNow; + _lockoutDuration = _lockoutUntil.Value - DateTimeOffset.UtcNow; + UpdateRemaining(); - private async Task LogoutAsync() - { - await UAuth.Flows.LogoutAsync(); - Snackbar.Add("Logged out", Severity.Success); - } + _lockoutCts?.Cancel(); + _lockoutCts = new CancellationTokenSource(); - private async Task RefreshAsync() - { - await UAuth.Flows.RefreshAsync(); - } + _lockoutTimer?.Dispose(); + _lockoutTimer = new PeriodicTimer(TimeSpan.FromSeconds(1)); - private async Task HandleGetMe() - { - var profileResult = await UAuth.Users.GetMeAsync(); - if (profileResult.Ok) + try { - var profile = profileResult.Value; - Snackbar.Add($"User Profile: {profile?.UserName} ({profile?.DisplayName})", Severity.Info); + while (await _lockoutTimer.WaitForNextTickAsync(_lockoutCts.Token)) + { + UpdateRemaining(); + + if (_remaining <= TimeSpan.Zero) + { + ResetLockoutState(); + await InvokeAsync(StateHasChanged); + break; + } + + await InvokeAsync(StateHasChanged); + } } - else + catch (OperationCanceledException) { - Snackbar.Add($"Failed to get profile: {profileResult.Error}", Severity.Error); + } } - private async Task ChangeUserInactive() + private void ResetLockoutState() { - ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest - { - UserKey = UserKey.FromString("user"), - NewStatus = UserStatus.Disabled - }; - var result = await UAuth.Users.ChangeStatusAdminAsync(request); - if (result.Ok) - { - Snackbar.Add($"User is disabled.", Severity.Info); - } - else - { - Snackbar.Add($"Failed to change user status.", Severity.Error); - } + _isLocked = false; + _lockoutUntil = null; + _progressPercent = 0; + _remainingAttempts = null; } - protected override void OnAfterRender(bool firstRender) + private void UpdateRemaining() { - if (firstRender) - { - var uri = Nav.ToAbsoluteUri(Nav.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + if (_lockoutUntil is null || _lockoutStartedAt is null) + return; - if (query.TryGetValue("error", out var error)) - { - ShowLoginError(error.ToString()); - ClearQueryString(); - } - } - } + var now = DateTimeOffset.UtcNow; - private void ShowLoginError(string code) - { - var message = code switch + _remaining = _lockoutUntil.Value - now; + + if (_remaining <= TimeSpan.Zero) { - "invalid" => "Invalid username or password.", - "locked" => "Your account is locked.", - "mfa" => "Multi-factor authentication required.", - _ => "Login failed." - }; + _remaining = TimeSpan.Zero; + return; + } - Snackbar.Add(message, Severity.Error); + var elapsed = now - _lockoutStartedAt.Value; + + if (_lockoutDuration.TotalSeconds > 0) + { + var percent = 100 - (elapsed.TotalSeconds / _lockoutDuration.TotalSeconds * 100); + _progressPercent = Math.Max(0, percent); + } } - private void ClearQueryString() + public override void Dispose() { - var uri = new Uri(Nav.Uri); - var clean = uri.GetLeftPart(UriPartial.Path); - Nav.NavigateTo(clean, replace: true); + 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..c4b58a61 --- /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/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor index 4c6c1796..c9f4b753 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor @@ -1,22 +1,60 @@ -@inject ISnackbar Snackbar - - - - - - - - - - - - - +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure +@inject ISnackbar Snackbar +@inject DarkModeManager DarkModeManager + + + + + + + + + + + + + + + + + + + + @code { - private void HandleReauth() + 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/Infrastructure/DarkModeManager.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DarkModeManager.cs new file mode 100644 index 00000000..7b2c1990 --- /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 IBrowserStorage _storage; + + public DarkModeManager(IBrowserStorage 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 index a38eb66f..19af773b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -16,6 +16,7 @@ using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; var builder = WebApplication.CreateBuilder(args); @@ -26,20 +27,25 @@ options.DetailedErrors = true; }); -builder.Services.AddMudServices(); +builder.Services.AddMudServices(o => { + o.SnackbarConfiguration.PreventDuplicates = false; +}); builder.Services.AddMudExtensions(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); builder.Services.AddUltimateAuthServer(o => { - o.Diagnostics.EnableRefreshHeaders = true; + 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.UserIdentifiers.AllowMultipleUsernames = true; }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() @@ -57,6 +63,7 @@ o.Reauth.Behavior = ReauthBehavior.RaiseEvent; }); +builder.Services.AddScoped(); var app = builder.Build(); 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 0000000000000000000000000000000000000000..5b7282f15f1b7e435a8e88356ed2351d1edcc26b GIT binary patch literal 14776 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 - - - - + + + + + + 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 index 3673ab70..55885729 100644 --- 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 @@ -1,4 +1,4 @@ - + net10.0 @@ -8,12 +8,12 @@ - + - + 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 index 7ea14029..f451950a 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor @@ -1,4 +1,96 @@ @inherits LayoutComponentBase +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar -@Body + + + UltimateAuth + Blazor Server Sample + + + + + + + + + Text + + + + + + + + + + + + + @(state.Identity?.PrimaryUserName?.Substring(0, 1).ToUpper()) + + + + + + + + + + + + + + @state.Identity?.PrimaryUserName + + + + + + + Refresh Session + + + + + Logout + + + + + + Reauthenticate + + + + + + Sign In + + + + + + + + + + @Body + + + +@code { + [CascadingParameter] + public UAuthState UAuth { get; set; } = default!; + + private async Task Refresh() + { + await UAuthClient.Flows.RefreshAsync(); + } + + private async Task Logout() + { + await UAuthClient.Flows.LogoutAsync(); + } +} 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 index d904484d..bcda64ca 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -91,7 +91,7 @@ ReauthRequired: @Diagnostics.RefreshReauthRequiredCount - Unknown: @Diagnostics.RefreshUnknownCount + Unknown: @Diagnostics.RefreshSuccessCount diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs index 77e8b19a..27871be7 100644 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Client.Contracts; -namespace CodeBeam.UltimateAuth.Client.Utilities; +namespace CodeBeam.UltimateAuth.Client.Infrastructure; public interface IBrowserStorage { diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticationStateProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticatonStateProvider.cs rename to src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticationStateProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs index f75f29ac..7b106dd0 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs @@ -25,6 +25,7 @@ private UAuthState() { } ///
public bool IsStale { get; private set; } + public event Action? Changed; public bool IsAuthenticated => Identity is not null; diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs index db7af1de..0751651d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs @@ -24,6 +24,12 @@ public async Task EnsureAsync(bool force = false, CancellationToken ct = default if (!result.IsValid || result.Snapshot == null) { + if (State.IsAuthenticated) + { + State.MarkStale(); + return; + } + State.Clear(); return; } diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor index a24e9179..47a45bad 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor @@ -1,12 +1,22 @@ @namespace CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Contracts @using Microsoft.AspNetCore.Components.Authorization @inject IUAuthStateManager StateManager @inject IUAuthClientBootstrapper Bootstrapper @inject ISessionCoordinator Coordinator - - @ChildContent + + @if (RenderMode == UAuthRenderMode.Reactive) + { + + @ChildContent + + } + else + { + @ChildContent + } diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs index c7bd0910..2c8c0786 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs @@ -10,6 +10,9 @@ public partial class UAuthApp [Parameter] public RenderFragment ChildContent { get; set; } = default!; + [Parameter] + public UAuthRenderMode RenderMode { get; set; } = UAuthRenderMode.Manual; + [Parameter] public EventCallback OnReauthRequired { get; set; } @@ -20,7 +23,10 @@ protected override async Task OnInitializedAsync() protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender || _initialized) + if (!firstRender) + return; + + if (_initialized) return; _initialized = true; @@ -43,10 +49,11 @@ private void OnStateChanged(UAuthStateChangeReason reason) { if (reason == UAuthStateChangeReason.MarkedStale) { - _ = InvokeAsync(async () => - { - await StateManager.EnsureAsync(); - }); + // Causes infinite loop + //_ = InvokeAsync(async () => + //{ + // await StateManager.EnsureAsync(); + //}); } if (reason == UAuthStateChangeReason.Authenticated) @@ -65,11 +72,15 @@ private void OnStateChanged(UAuthStateChangeReason reason) }); } - InvokeAsync(StateHasChanged); + if (RenderMode == UAuthRenderMode.Reactive) + { + InvokeAsync(StateHasChanged); + } } private async void HandleReauthRequired() { + StateManager.MarkStale(); if (OnReauthRequired.HasDelegate) await OnReauthRequired.InvokeAsync(); } diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs new file mode 100644 index 00000000..34a3ffc6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs @@ -0,0 +1,104 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Client; + +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 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; + + 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/CodeBeam.UltimateAuth.Client/Components/UAuthReactiveComponentBase.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthReactiveComponentBase.cs new file mode 100644 index 00000000..aed94959 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthReactiveComponentBase.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client; + +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/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor new file mode 100644 index 00000000..deb5e819 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor @@ -0,0 +1,4 @@ +@namespace CodeBeam.UltimateAuth.Client +@inherits UAuthReactiveComponentBase + +@ChildContent diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor.cs new file mode 100644 index 00000000..86bf75e5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace CodeBeam.UltimateAuth.Client; + +public partial class UAuthScope : UAuthReactiveComponentBase +{ + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor new file mode 100644 index 00000000..95894528 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor @@ -0,0 +1,35 @@ +@namespace CodeBeam.UltimateAuth.Client + +@inherits UAuthReactiveComponentBase +@using CodeBeam.UltimateAuth.Core.Domain +@using Microsoft.AspNetCore.Components.Authorization + +@if (_inactive) +{ + if (Inactive is not null) + { + @Inactive(AuthState) + } + else + { + @NotAuthorized + } +} +else +{ + + + @if (Authorized is not null) + { + @Authorized(AuthState) + } + else if (ChildContent is not null) + { + @ChildContent(AuthState) + } + + + @NotAuthorized + + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs new file mode 100644 index 00000000..824f7107 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs @@ -0,0 +1,65 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client; + +public partial class UAuthStateView : UAuthReactiveComponentBase +{ + [Parameter] + public RenderFragment? Authorized { get; set; } + + [Parameter] + public RenderFragment? NotAuthorized { get; set; } + + [Parameter] + public RenderFragment? Inactive { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string? Roles { get; set; } + + [Parameter] + public string? Policy { get; set; } + + [Parameter] + public bool RequireActive { get; set; } = true; + + private bool _inactive; + + protected override void OnParametersSet() + { + base.OnParametersSet(); + EvaluateSessionState(); + } + + protected override void HandleAuthStateChanged(UAuthStateChangeReason reason) + { + EvaluateSessionState(); + } + + 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; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs index 5e35d6ac..f04f078b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Client.Contracts; public sealed record RefreshResult { - public bool Ok { get; init; } + public bool IsSuccess { get; init; } public int Status { get; init; } public RefreshOutcome Outcome { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs new file mode 100644 index 00000000..76c9fba4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Client; + +public enum UAuthRenderMode +{ + Manual = 0, + Reactive = 1 +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs b/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs index 747acb6d..1fd9fc47 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs +++ b/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs @@ -1,11 +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/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs index 7bb5f7f2..2c529c99 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs @@ -1,5 +1,5 @@ using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Client.Utilities; +using CodeBeam.UltimateAuth.Client.Infrastructure; namespace CodeBeam.UltimateAuth.Client.Device; diff --git a/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs index f36f54bb..927cb991 100644 --- a/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs +++ b/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs @@ -29,7 +29,7 @@ public sealed class UAuthClientDiagnostics public int RefreshTouchedCount { get; private set; } public int RefreshNoOpCount { get; private set; } public int RefreshReauthRequiredCount { get; private set; } - public int RefreshUnknownCount { get; private set; } + public int RefreshSuccessCount { get; private set; } public TimeSpan? RunningDuration => StartedAt is null @@ -87,9 +87,9 @@ internal void MarkRefreshReauthRequired() Changed?.Invoke(); } - internal void MarkRefreshUnknown() + internal void MarkRefreshSuccess() { - RefreshUnknownCount++; + RefreshSuccessCount++; Changed?.Invoke(); } diff --git a/src/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs b/src/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs new file mode 100644 index 00000000..27ecb2cd --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs b/src/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs new file mode 100644 index 00000000..7b7146a0 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs b/src/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs new file mode 100644 index 00000000..b41c0f6a --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index e820524e..5c4e1525 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -7,7 +7,6 @@ using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Client.Runtime; using CodeBeam.UltimateAuth.Client.Services; -using CodeBeam.UltimateAuth.Client.Utilities; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Components.Authorization; diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs index e270af2a..6c3dff3e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Client.Contracts; using Microsoft.JSInterop; -namespace CodeBeam.UltimateAuth.Client.Utilities; +namespace CodeBeam.UltimateAuth.Client.Infrastructure; public sealed class BrowserStorage : IBrowserStorage { diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs index 484d9b8f..d43838bc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs @@ -8,7 +8,5 @@ public interface IUAuthRequestClient Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); - Task SendFormForJsonAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); - Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs index 88cb7aa5..261cbd6d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs @@ -7,14 +7,15 @@ internal static class RefreshOutcomeParser public static RefreshOutcome Parse(string? value) { if (string.IsNullOrWhiteSpace(value)) - return RefreshOutcome.None; + return RefreshOutcome.Success; return value switch { "no-op" => RefreshOutcome.NoOp, "touched" => RefreshOutcome.Touched, + "rotated" => RefreshOutcome.Rotated, "reauth-required" => RefreshOutcome.ReauthRequired, - _ => RefreshOutcome.None + _ => RefreshOutcome.Success }; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs index ad1f176b..96a79bec 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs @@ -61,7 +61,7 @@ private async Task RunAsync(CancellationToken ct) case RefreshOutcome.NoOp: break; - case RefreshOutcome.None: + case RefreshOutcome.Success: break; case RefreshOutcome.ReauthRequired: diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs index f0de6f16..7cb88931 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs @@ -1,7 +1,9 @@ using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; using CodeBeam.UltimateAuth.Client.Options; using Microsoft.Extensions.Options; using Microsoft.JSInterop; +using System.Net; // TODO: Add fluent helper API like RequiredOk namespace CodeBeam.UltimateAuth.Client.Infrastructure; @@ -44,30 +46,20 @@ public async Task SendFormAsync(string endpoint, IDictiona { url = endpoint, mode = "fetch", - expectJson = false, data = form, clientProfile = _options.ClientProfile.ToString() }); - return result; - } + if (result == null) + throw new UAuthProtocolException("Invalid error response format."); - public async Task SendFormForJsonAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + if (result.Status == 0) + throw new UAuthTransportException("Network error."); - await _bootstrapper.EnsureStartedAsync(); + if (result.Status >= 500) + throw new UAuthTransportException($"Server error {result.Status}", (HttpStatusCode)result.Status); - var postData = form ?? new Dictionary(); - return await _js.InvokeAsync("uauth.post", ct, - new - { - url = endpoint, - mode = "fetch", - expectJson = true, - data = postData, - clientProfile = _options.ClientProfile.ToString() - }); + return result; } public async Task SendJsonAsync(string endpoint, object? payload = default, CancellationToken ct = default) @@ -76,12 +68,22 @@ public async Task SendJsonAsync(string endpoint, object? p await _bootstrapper.EnsureStartedAsync(); - return await _js.InvokeAsync("uauth.postJson", ct, new + 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; + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index cf4ebfea..2156fafa 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -1,19 +1,29 @@ 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) { - if (!raw.Ok) + EnsureTransport(raw); + + if (raw.Status >= 400 && raw.Status < 500) { + var problem = TryDeserializeProblem(raw); + return new UAuthResult { - Ok = false, - Status = raw.Status + IsSuccess = false, + Status = raw.Status, + Problem = problem }; } @@ -21,30 +31,52 @@ public static UAuthResult FromJson(UAuthTransportResult raw) { return new UAuthResult { - Ok = true, + IsSuccess = true, Status = raw.Status, Value = default }; } - var value = raw.Body.Value.Deserialize( - new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + try + { + var value = raw.Body.Value.Deserialize(_jsonOptions); - return new UAuthResult + return new UAuthResult + { + IsSuccess = true, + Status = raw.Status, + Value = value + }; + } + catch (JsonException ex) { - Ok = true, - Status = raw.Status, - Value = value - }; + throw new UAuthProtocolException("Invalid response format.", ex); + } } - public static UAuthResult FromStatus(UAuthTransportResult raw) - => new() + 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 { - Ok = raw.Ok, - Status = raw.Status - }; + return null; + } + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs index 3d85ecf6..bec146de 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Core.Contracts; +// TODO: Add ReauthAsync namespace CodeBeam.UltimateAuth.Client.Services; public interface IFlowClient @@ -8,7 +9,7 @@ public interface IFlowClient Task LoginAsync(LoginRequest request, string? returnUrl = null); Task LogoutAsync(); Task RefreshAsync(bool isAuto = false); - Task ReauthAsync(); + //Task ReauthAsync(); Task ValidateAsync(); Task BeginPkceAsync(string? returnUrl = null); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index fd79168e..4cfc6689 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -28,13 +28,13 @@ public async Task> CheckAsync(AuthorizationChec public async Task> GetMyRolesAsync() { - var raw = await _request.SendFormForJsonAsync(Url("/authorization/users/me/roles/get")); + var raw = await _request.SendFormAsync(Url("/authorization/users/me/roles/get")); return UAuthResultMapper.FromJson(raw); } public async Task> GetUserRolesAsync(UserKey userKey) { - var raw = await _request.SendFormForJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/get")); + var raw = await _request.SendFormAsync(Url($"/admin/authorization/users/{userKey}/roles/get")); return UAuthResultMapper.FromJson(raw); } @@ -45,7 +45,7 @@ public async Task AssignRoleAsync(UserKey userKey, string role) Role = role }); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task RemoveRoleAsync(UserKey userKey, string role) @@ -55,6 +55,6 @@ public async Task RemoveRoleAsync(UserKey userKey, string role) Role = role }); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 1d1d1be2..1ef35801 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -22,7 +22,7 @@ public UAuthCredentialClient(IUAuthRequestClient request, IOptions> GetMyAsync() { - var raw = await _request.SendFormForJsonAsync(Url("/credentials/get")); + var raw = await _request.SendFormAsync(Url("/credentials/get")); return UAuthResultMapper.FromJson(raw); } @@ -41,25 +41,25 @@ public async Task> ChangeMyAsync(CredentialT public async Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/revoke"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/begin"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/complete"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task> GetUserAsync(UserKey userKey) { - var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey}/credentials/get")); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/get")); return UAuthResultMapper.FromJson(raw); } @@ -72,31 +72,31 @@ public async Task> AddUserAsync(UserKey userKey public async Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/revoke"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task ActivateUserAsync(UserKey userKey, CredentialType type) { var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/activate")); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/begin"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/complete"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task DeleteUserAsync(UserKey userKey, CredentialType type) { var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/delete")); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 2cea3e7c..3c81d96e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Errors; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; @@ -8,6 +9,7 @@ using CodeBeam.UltimateAuth.Core.Infrastructure; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; +using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -74,6 +76,18 @@ public async Task RefreshAsync(bool isAuto = false) 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) { @@ -86,50 +100,61 @@ public async Task RefreshAsync(bool isAuto = false) case RefreshOutcome.ReauthRequired: _diagnostics.MarkRefreshReauthRequired(); break; - case RefreshOutcome.None: - _diagnostics.MarkRefreshUnknown(); + case RefreshOutcome.Success: + _diagnostics.MarkRefreshSuccess(); break; } return new RefreshResult { - Ok = result.Ok, + IsSuccess = result.Ok, Status = result.Status, Outcome = refreshOutcome }; } - public async Task ReauthAsync() - { - var url = Url(_options.Endpoints.Reauth); - await _post.NavigateAsync(_options.Endpoints.Reauth); - } + //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.SendFormForJsonAsync(url); + var raw = await _post.SendFormAsync(url); - if (!raw.Ok || raw.Body is null) + 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 { - return new AuthValidationResult - { - IsValid = false, - State = "transport" - }; + body = raw.Body.Value.Deserialize( + new JsonSerializerOptions{ PropertyNameCaseInsensitive = true }); + } + catch (Exception ex) + { + throw new UAuthProtocolException("Invalid validation response format.", ex); } - var body = raw.Body.Value.Deserialize( - new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + if (body is null) + throw new UAuthProtocolException("Malformed validation response."); - return body ?? new AuthValidationResult - { - IsValid = false, - State = "deserialize" - }; + if (raw.Status == 401 || (raw.Status >= 200 && raw.Status < 300)) + 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) @@ -144,7 +169,7 @@ public async Task BeginPkceAsync(string? returnUrl = null) var authorizeUrl = Url(_options.Endpoints.PkceAuthorize); - var raw = await _post.SendFormForJsonAsync( + var raw = await _post.SendFormAsync( authorizeUrl, new Dictionary { diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 250b4733..3f2c426b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -22,14 +22,14 @@ public UAuthUserClient(IUAuthRequestClient request, IOptions public async Task> GetMeAsync() { - var raw = await _request.SendFormForJsonAsync(Url("/users/me/get")); + var raw = await _request.SendFormAsync(Url("/users/me/get")); return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/update"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task> CreateAsync(CreateUserRequest request) @@ -58,13 +58,13 @@ public async Task> DeleteAsync(DeleteUserRequest r public async Task> GetProfileAsync(UserKey userKey) { - var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey}/profile/get")); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/profile/get")); return UAuthResultMapper.FromJson(raw); } public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/profile/update"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index 96c3460b..7a814c6c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Client.Services; -public class UAuthUserIdentifierClient : IUserIdentifierClient +internal class UAuthUserIdentifierClient : IUserIdentifierClient { private readonly IUAuthRequestClient _request; private readonly UAuthClientOptions _options; @@ -22,85 +22,85 @@ public UAuthUserIdentifierClient(IUAuthRequestClient request, IOptions>> GetMyIdentifiersAsync() { - var raw = await _request.SendFormForJsonAsync(Url("/users/me/identifiers/get")); + var raw = await _request.SendFormAsync(Url("/users/me/identifiers/get")); return UAuthResultMapper.FromJson>(raw); } public async Task AddSelfAsync(AddUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/add"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/update"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/set-primary"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/unset-primary"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/verify"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/delete"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task>> GetUserIdentifiersAsync(UserKey userKey) { - var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/get")); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/identifiers/get")); return UAuthResultMapper.FromJson>(raw); } public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/add"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/update"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/set-primary"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/unset-primary"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/verify"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } public async Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/delete"), request); - return UAuthResultMapper.FromStatus(raw); + return UAuthResultMapper.From(raw); } } diff --git a/src/CodeBeam.UltimateAuth.Client/TScripts/uauth.js b/src/CodeBeam.UltimateAuth.Client/TScripts/uauth.js index c627bab1..6aba29d1 100644 --- a/src/CodeBeam.UltimateAuth.Client/TScripts/uauth.js +++ b/src/CodeBeam.UltimateAuth.Client/TScripts/uauth.js @@ -59,7 +59,6 @@ window.uauth.post = async function (options) { url, mode, data, - expectJson, clientProfile } = options; @@ -122,12 +121,10 @@ window.uauth.post = async function (options) { }); let responseBody = null; - if (expectJson) { - try { - responseBody = await response.json(); - } catch { - responseBody = null; - } + try { + responseBody = await response.json(); + } catch { + responseBody = null; } return { @@ -164,7 +161,6 @@ window.uauth.postJson = async function (options) { }); let responseBody = null; - try { responseBody = await response.json(); } catch { diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js index 0a57addb..5b835c21 100644 --- a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js +++ b/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(n){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.post=async function(n){const{url:f,mode:s,data:t,expectJson:h,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;if(h)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} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(n){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.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} \ No newline at end of file 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 index c62d4b68..1c07c5ad 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs @@ -12,4 +12,6 @@ public sealed record AuthIdentitySnapshot 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; } } 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 index 31f83adb..be87ad8b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -2,14 +2,27 @@ public class UAuthResult { - public bool Ok { get; init; } + public bool IsSuccess { get; init; } public int Status { get; init; } - public string? Error { get; init; } - public string? ErrorCode { get; init; } + public UAuthProblem? Problem { get; init; } - public bool IsUnauthorized => Status == 401; - public bool IsForbidden => Status == 403; + public HttpStatusInfo Http => new(Status); + + 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 diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs index 31548156..93c84a09 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -10,17 +10,21 @@ public sealed record LoginResult public RefreshToken? 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) + public static LoginResult Failed(AuthFailureReason? reason = null, DateTimeOffset? lockoutUntilUtc = null, int? remainingAttempts = null) => new() { Status = LoginStatus.Failed, - FailureReason = reason + FailureReason = reason, + LockoutUntilUtc = lockoutUntilUtc, + RemainingAttempts = remainingAttempts }; public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs index 0baf518b..7a617737 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs @@ -1,25 +1,11 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record AuthValidationResult { - public bool IsValid { get; init; } - public string? State { get; init; } - public int? RemainingAttempts { get; init; } - + public required SessionState State { get; init; } public AuthStateSnapshot? Snapshot { get; init; } - public static AuthValidationResult Valid(AuthStateSnapshot? snapshot = null) - => new() - { - IsValid = true, - State = "active", - Snapshot = snapshot - }; - - public static AuthValidationResult Invalid(string state) - => new() - { - IsValid = false, - State = state - }; + public bool IsValid => State == SessionState.Active; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs index 7d3ba4cd..b0b148ed 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs @@ -9,5 +9,6 @@ public enum AuthFailureReason SessionRevoked, TenantDisabled, Unauthorized, + ReauthenticationRequired, Unknown } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs index f0aa8db4..27229e1f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs @@ -2,7 +2,7 @@ public enum RefreshOutcome { - None, + Success, // minimal transport NoOp, Touched, Rotated, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs index a2c01f45..112c33e8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs @@ -12,5 +12,6 @@ public enum SessionState NotFound, Invalid, SecurityMismatch, - DeviceMismatch + DeviceMismatch, + Unsupported } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs index 892c6e06..6aaae599 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs @@ -2,8 +2,8 @@ public sealed class UAuthChallengeRequiredException : UAuthException { - public UAuthChallengeRequiredException(string? reason = null) - : base(reason ?? "Additional authentication is required to perform this operation.") + public UAuthChallengeRequiredException(string? reason = null) + : base(code: "challenge_required", message: reason ?? "Additional authentication is required.") { } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs index 783b3125..b4f1ad19 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs @@ -1,9 +1,9 @@ namespace CodeBeam.UltimateAuth.Core.Errors; -public sealed class UAuthAuthorizationException : UAuthException +public sealed class UAuthAuthorizationException : UAuthRuntimeException { public UAuthAuthorizationException(string? reason = null) - : base(reason ?? "The current principal is not authorized to perform this operation.") + : base(code: "forbidden", message: reason ?? "The current principal is not authorized to perform this operation.") { } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs deleted file mode 100644 index fee2bb4a..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -public abstract class UAuthChainException : UAuthDomainException -{ - public SessionChainId ChainId { get; } - - protected UAuthChainException(SessionChainId chainId, string message) : base(message) - { - ChainId = chainId; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs index bc55cadd..a1c4f367 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs @@ -1,17 +1,8 @@ namespace CodeBeam.UltimateAuth.Core.Errors; -/// -/// Represents an exception that indicates a developer integration error -/// rather than a runtime or authentication failure. -/// These errors typically occur when UltimateAuth is misconfigured, -/// required services are not registered, or contracts are violated by the host application. -/// public abstract class UAuthDeveloperException : UAuthException { - /// - /// Initializes a new instance of the class - /// with a specified error message describing the developer mistake. - /// - /// The error message explaining the incorrect usage. - protected UAuthDeveloperException(string message) : base(message) { } + 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 index 7a19e378..20e974c5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs @@ -1,15 +1,8 @@ namespace CodeBeam.UltimateAuth.Core.Errors; -/// -/// Represents an exception triggered by a violation of UltimateAuth's domain rules or invariants. -/// These errors indicate that a business rule or authentication domain constraint has been broken (e.g., invalid session state transition, -/// illegal revoke action, or inconsistent security version). -/// public abstract class UAuthDomainException : UAuthException { - /// - /// Initializes a new instance of the class with a message describing the violated domain rule. - /// - /// The descriptive message for the domain error. - protected UAuthDomainException(string message) : base(message) { } -} + 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 index 13662dc2..92d148a4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs @@ -1,24 +1,16 @@ namespace CodeBeam.UltimateAuth.Core.Errors; -/// -/// Represents the base type for all exceptions thrown by the UltimateAuth framework. -/// This class differentiates authentication-domain errors from general system exceptions -/// and provides a common abstraction for developer, domain, and runtime error types. -/// public abstract class UAuthException : Exception { - /// - /// Initializes a new instance of the class - /// with the specified error message. - /// - /// The message that describes the error. - protected UAuthException(string message) : base(message) { } + public string Code { get; } - /// - /// Initializes a new instance of the class - /// with the specified error message and underlying exception. - /// - /// The message that describes the error. - /// The exception that caused the current error. - protected UAuthException(string message, Exception? inner) : base(message, inner) { } + 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/Base/UAuthSessionException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs deleted file mode 100644 index a27d9b9f..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthSessionException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents a domain-level exception associated with a specific authentication session. -/// This error indicates that a session-related invariant or rule has been violated, -/// such as attempting to refresh a revoked session, using an expired session, -/// or performing an operation that conflicts with the session's current state. -/// -public abstract class UAuthSessionException : UAuthDomainException -{ - /// - /// Gets the identifier of the session that triggered the exception. - /// - public AuthSessionId SessionId { get; } - - /// - /// Initializes a new instance of the class with the session identifier and an explanatory error message. - /// - /// The session identifier associated with the error. - /// The message describing the session rule violation. - protected UAuthSessionException(AuthSessionId sessionId, string message) : base(message) - { - SessionId = sessionId; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs deleted file mode 100644 index 8710b51e..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthConfigException.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an exception that is thrown when UltimateAuth is configured -/// incorrectly or when required configuration values are missing or invalid. -/// This error indicates a developer-side setup issue rather than a runtime -/// authentication failure. -/// -public sealed class UAuthConfigException : UAuthDeveloperException -{ - /// - /// Initializes a new instance of the class - /// with a descriptive message explaining the configuration problem. - /// - /// The message describing the configuration error. - public UAuthConfigException(string message) : base(message) { } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs deleted file mode 100644 index 703b8acb..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthInternalException.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an unexpected internal error within the UltimateAuth framework. -/// This exception indicates a failure in internal logic, invariants, or service -/// coordination, rather than a configuration or authentication mistake by the developer. -/// -/// If this exception occurs, it typically means a bug or unhandled scenario -/// exists inside the framework itself. -/// -public sealed class UAuthInternalException : UAuthDeveloperException -{ - /// - /// Initializes a new instance of the class - /// with a descriptive message explaining the internal framework error. - /// - /// The internal error message. - public UAuthInternalException(string message) : base(message) { } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs deleted file mode 100644 index 0d5eae3d..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Developer/UAuthStoreException.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an exception that occurs when a session or user store -/// behaves incorrectly or violates the UltimateAuth storage contract. -/// This typically indicates an implementation error in the application's -/// persistence layer rather than a framework or authentication issue. -/// -public sealed class UAuthStoreException : UAuthDeveloperException -{ - /// - /// Initializes a new instance of the class - /// with a descriptive message explaining the store failure. - /// - /// The message describing the store-related error. - public UAuthStoreException(string message) : base(message) { } -} 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/Session/UAuthChainLinkMissingException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs deleted file mode 100644 index 2294ae56..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthSessionChainLinkMissingException : UAuthSessionException -{ - public UAuthSessionChainLinkMissingException(AuthSessionId sessionId) - : base(sessionId, $"Session '{sessionId}' is not associated with any session chain.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs deleted file mode 100644 index 40a9b8d0..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthSessionChainNotFoundException : UAuthChainException -{ - public UAuthSessionChainNotFoundException(SessionChainId chainId) - : base(chainId, $"Session chain '{chainId}' was not found.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs deleted file mode 100644 index bc08a223..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthSessionChainRevokedException : UAuthChainException -{ - public UAuthSessionChainRevokedException(SessionChainId chainId) - : base(chainId, $"Session chain '{chainId}' has been revoked.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs deleted file mode 100644 index f6435ad8..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthSessionDeviceMismatchException : UAuthSessionException -{ - public DeviceContext Expected { get; } - public DeviceContext Actual { get; } - - public UAuthSessionDeviceMismatchException(AuthSessionId sessionId, DeviceContext expected, DeviceContext actual) - : base(sessionId, $"Session '{sessionId}' device mismatch detected.") - { - Expected = expected; - Actual = actual; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs deleted file mode 100644 index aa7277ad..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an authentication-domain exception thrown when a session -/// has passed its expiration time. -/// -/// This exception is raised during validation or refresh attempts where -/// the session's timestamp -/// indicates that it is no longer valid. -/// -/// Once expired, a session cannot be refreshed — the user must log in again. -/// -public sealed class UAuthSessionExpiredException : UAuthSessionException -{ - /// - /// Initializes a new instance of the class - /// using the expired session's identifier. - /// - /// The identifier of the expired session. - public UAuthSessionExpiredException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' has expired.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs deleted file mode 100644 index 64d5b793..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthSessionInvalidStateException : UAuthSessionException -{ - public SessionState State { get; } - - public UAuthSessionInvalidStateException(AuthSessionId sessionId, SessionState state) - : base(sessionId, $"Session '{sessionId}' is in invalid state '{state}'.") - { - State = state; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs deleted file mode 100644 index a336dd17..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an authentication-domain exception thrown when a session exists -/// but is not in the state. -/// This exception typically occurs during validation or refresh operations when: -/// - the session is revoked, -/// - the session has expired, -/// - the session belongs to a revoked chain, -/// - or the session is otherwise considered inactive by the runtime state machine. -/// Only active sessions are eligible for refresh and token issuance. -/// -public sealed class UAuthSessionNotActiveException : UAuthSessionException -{ - /// - /// Initializes a new instance of the class. - /// - /// The identifier of the session that is not active. - public UAuthSessionNotActiveException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' is not active.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs deleted file mode 100644 index b7f32d8f..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthSessionNotFoundException : UAuthSessionException -{ - public UAuthSessionNotFoundException(AuthSessionId sessionId) - : base(sessionId, $"Session '{sessionId}' was not found.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs deleted file mode 100644 index 1bc96c93..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an authentication-domain exception thrown when an operation attempts -/// to use a session that has been explicitly revoked by the user, administrator, -/// or by system-driven security policies. -/// -/// A revoked session is permanently invalid and cannot be refreshed, validated, -/// or used to obtain new tokens. Revocation typically occurs during actions such as -/// logout, device removal, or administrative account lockdown. -/// -/// This exception is raised in scenarios where a caller assumes the session is active -/// but the underlying session state indicates . -/// -public sealed class UAuthSessionRevokedException : UAuthSessionException -{ - /// - /// Initializes a new instance of the class. - /// - /// The identifier of the revoked session. - public UAuthSessionRevokedException(AuthSessionId sessionId) : base(sessionId, $"Session '{sessionId}' has been revoked.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs deleted file mode 100644 index 985eaef7..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthSessionRootRevokedException : Exception -{ - public object UserId { get; } - - public UAuthSessionRootRevokedException(object userId) - : base("All sessions for the user have been revoked.") - { - UserId = userId; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs deleted file mode 100644 index 657fc543..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthSessionSecurityMismatchException : UAuthSessionException -{ - public long CurrentSecurityVersion { get; } - - public UAuthSessionSecurityMismatchException( - AuthSessionId sessionId, - long currentSecurityVersion) - : base( - sessionId, - $"Session '{sessionId}' is invalid due to security version mismatch.") - { - CurrentSecurityVersion = currentSecurityVersion; - } -} 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/UAuthDeviceLimitException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs deleted file mode 100644 index c4aaa375..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthDeviceLimitException.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents a domain-level exception that is thrown when a user exceeds the allowed number of device or platform-specific session chains. -/// This typically occurs when UltimateAuth's session policy restricts the -/// number of concurrent logins for a given platform (e.g., web, mobile) -/// and the user attempts to create an additional session beyond the limit. -/// -public sealed class UAuthDeviceLimitException : UAuthDomainException -{ - /// - /// Gets the platform for which the device or session-chain limit was exceeded. - /// - public string Platform { get; } - - /// - /// Initializes a new instance of the class with the specified platform name. - /// - /// The platform on which the limit was exceeded. - public UAuthDeviceLimitException(string platform) : base($"Device limit exceeded for platform '{platform}'.") - { - Platform = platform; - } -} 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/UAuthInvalidCredentialsException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs deleted file mode 100644 index 8e573a85..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidCredentialsException.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an authentication failure caused by invalid user credentials. -/// This error is thrown when the supplied username, password, or login -/// identifier does not match any valid user account. -/// -public sealed class UAuthInvalidCredentialsException : UAuthDomainException -{ - /// - /// Initializes a new instance of the class - /// with a default message indicating incorrect credentials. - /// - public UAuthInvalidCredentialsException() : base("Invalid username or password.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs deleted file mode 100644 index 574d269b..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthInvalidPkceCodeException.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an authentication failure occurring during the PKCE authorization -/// flow when the supplied authorization code is invalid, expired, or does not -/// match the original code challenge. -/// This exception indicates a failed PKCE verification rather than a general -/// credential or configuration error. -/// -public sealed class UAuthInvalidPkceCodeException : UAuthDomainException -{ - /// - /// Initializes a new instance of the class - /// with a default message indicating an invalid PKCE authorization code. - /// - public UAuthInvalidPkceCodeException() : base("Invalid PKCE authorization code.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs new file mode 100644 index 00000000..7adef0fa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthNotFoundException : UAuthRuntimeException +{ + public UAuthNotFoundException(string code) : base(code, "Resource not found.") + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs deleted file mode 100644 index b0426bd4..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthRootRevokedException.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents a domain-level authentication failure indicating that the user's -/// entire session root has been revoked. -/// When a root is revoked, all session chains and all sessions belonging to the -/// user become immediately invalid, regardless of their individual expiration -/// or revocation state. -/// -public sealed class UAuthRootRevokedException : UAuthDomainException -{ - /// - /// Initializes a new instance of the class - /// with a default message indicating that all sessions under the root are invalid. - /// - public UAuthRootRevokedException() : base("User root has been revoked. All sessions are invalid.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs deleted file mode 100644 index 66214ab2..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthTokenTamperedException.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -/// -/// Represents an authentication-domain exception thrown when a token fails its -/// integrity verification checks, indicating that the token may have been altered, -/// corrupted, or tampered with after issuance. -/// -/// This exception is raised during token validation when signature verification fails, -/// claims are inconsistent, or protected fields do not match their expected values. -/// Such failures generally imply either client-side manipulation or -/// man-in-the-middle interference. -/// -/// Applications catching this exception should treat the associated token as unsafe -/// and deny access immediately. Reauthentication or complete session invalidation -/// may be required depending on the security policy. -/// -public sealed class UAuthTokenTamperedException : UAuthDomainException -{ - /// - /// Initializes a new instance of the class. - /// - public UAuthTokenTamperedException() : base("Token integrity check failed (possible tampering).") - { - } -} 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/Infrastructure/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs index 083a6555..9949db9b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs @@ -58,9 +58,6 @@ public string ToCanonicalString(TUserId id) /// /// The string representation of the user id. /// The reconstructed user id. - /// - /// Thrown when deserialization of complex types fails. - /// public TUserId FromString(string value) { return typeof(TUserId) switch @@ -72,7 +69,7 @@ public TUserId FromString(string value) Type t when t == typeof(long) => (TUserId)(object)long.Parse(value, CultureInfo.InvariantCulture), _ => JsonSerializer.Deserialize(value) - ?? throw new UAuthInternalException("Cannot deserialize TUserId") + ?? throw new InvalidCastException("Cannot deserialize TUserId") }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 60621b08..18bebe32 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -14,13 +14,28 @@ public sealed class UAuthLoginOptions public int MaxFailedAttempts { get; set; } = 10; /// - /// Duration (in minutes) for which the user is locked out after exceeding . + /// Duration for which the user is locked out after exceeding . /// - public int LockoutMinutes { get; set; } = 15; + 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); internal UAuthLoginOptions Clone() => new() { MaxFailedAttempts = MaxFailedAttempts, - LockoutMinutes = LockoutMinutes + LockoutDuration = LockoutDuration, + IncludeFailureDetails = IncludeFailureDetails }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs index f4cb1f97..727dcd1c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs @@ -14,7 +14,7 @@ public ValidateOptionsResult Validate(string? name, UAuthLoginOptions options) if (options.MaxFailedAttempts > 100) errors.Add("Login.MaxFailedAttempts cannot exceed 100. Use 0 to disable lockout."); - if (options.MaxFailedAttempts > 0 && options.LockoutMinutes <= 0) + 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 diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs index 88122509..84bdbcb1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs @@ -7,10 +7,12 @@ namespace CodeBeam.UltimateAuth.Server.Auth internal sealed class AuthStateSnapshotFactory : IAuthStateSnapshotFactory { private readonly IPrimaryUserIdentifierProvider _identifierProvider; + private readonly IUserProfileSnapshotProvider _profileSnapshotProvider; - public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvider) + public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvider, IUserProfileSnapshotProvider profileSnapshotProvider) { _identifierProvider = identifierProvider; + _profileSnapshotProvider = profileSnapshotProvider; } public async Task CreateAsync(SessionValidationResult validation, CancellationToken ct = default) @@ -19,6 +21,7 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvide return null; var identifiers = await _identifierProvider.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var profile = await _profileSnapshotProvider.GetAsync(validation.Tenant, validation.UserKey.Value, ct); var identity = new AuthIdentitySnapshot { @@ -27,8 +30,10 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvide PrimaryUserName = identifiers?.UserName, PrimaryEmail = identifiers?.Email, PrimaryPhone = identifiers?.Phone, - DisplayName = identifiers?.DisplayName, - AuthenticatedAt = validation.AuthenticatedAt + DisplayName = profile?.DisplayName, + TimeZone = profile?.TimeZone, + AuthenticatedAt = validation.AuthenticatedAt, + SessionState = validation.State }; return new AuthStateSnapshot diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs index c81d6aa6..9b91c745 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs @@ -8,37 +8,41 @@ public sealed class EffectiveRedirectResponse public bool Enabled { get; } public string? SuccessPath { get; } public string? FailurePath { get; } - public string? FailureQueryKey { 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, - string? failureQueryKey, IReadOnlyDictionary? failureCodes, - bool allowReturnUrlOverride) + bool allowReturnUrlOverride, + bool includeLockoutTiming, + bool includeRemainingAttempts) { Enabled = enabled; SuccessPath = successPath; FailurePath = failurePath; - FailureQueryKey = failureQueryKey; FailureCodes = failureCodes; AllowReturnUrlOverride = allowReturnUrlOverride; + IncludeLockoutTiming = includeLockoutTiming; + IncludeRemainingAttempts = includeRemainingAttempts; } - public static readonly EffectiveRedirectResponse Disabled = new(false, null, null, null, null, false); + 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.FailureQueryKey, - login.FailureCodes, - login.AllowReturnUrlOverride - ); + => new( + login.RedirectEnabled, + login.SuccessRedirect, + login.FailureRedirect, + login.FailureCodes, + login.AllowReturnUrlOverride, + login.IncludeLockoutTiming, + login.IncludeRemainingAttempts + ); public static EffectiveRedirectResponse FromLogout(LogoutRedirectOptions logout) => new( @@ -46,7 +50,8 @@ public static EffectiveRedirectResponse FromLogout(LogoutRedirectOptions logout) logout.RedirectUrl, null, null, - null, - logout.AllowReturnUrlOverride + logout.AllowReturnUrlOverride, + false, + false ); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs index 3050581d..9639a42f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs @@ -1,16 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; +//using CodeBeam.UltimateAuth.Core.Domain; +//using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints; +//namespace CodeBeam.UltimateAuth.Server.Endpoints; -internal sealed class LoginEndpointHandlerBridge : ILoginEndpointHandler -{ - private readonly LoginEndpointHandler _inner; +//internal sealed class LoginEndpointHandlerBridge : ILoginEndpointHandler +//{ +// private readonly LoginEndpointHandler _inner; - public LoginEndpointHandlerBridge(LoginEndpointHandler inner) - { - _inner = inner; - } +// public LoginEndpointHandlerBridge(LoginEndpointHandler inner) +// { +// _inner = inner; +// } - public Task LoginAsync(HttpContext ctx) => _inner.LoginAsync(ctx); -} +// 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 index d6d79662..710cf06e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs @@ -1,17 +1,17 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; +//using CodeBeam.UltimateAuth.Core.Domain; +//using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints; +//namespace CodeBeam.UltimateAuth.Server.Endpoints; -internal sealed class LogoutEndpointHandlerBridge : ILogoutEndpointHandler -{ - private readonly LogoutEndpointHandler _inner; +//internal sealed class LogoutEndpointHandlerBridge : ILogoutEndpointHandler +//{ +// private readonly LogoutEndpointHandler _inner; - public LogoutEndpointHandlerBridge(LogoutEndpointHandler inner) - { - _inner = inner; - } +// public LogoutEndpointHandlerBridge(LogoutEndpointHandler inner) +// { +// _inner = inner; +// } - public Task LogoutAsync(HttpContext ctx) - => _inner.LogoutAsync(ctx); -} +// 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 index 6d515515..1b5aef95 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs @@ -1,18 +1,18 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; +//using CodeBeam.UltimateAuth.Core.Domain; +//using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints; +//namespace CodeBeam.UltimateAuth.Server.Endpoints; -internal sealed class PkceEndpointHandlerBridge : IPkceEndpointHandler -{ - private readonly PkceEndpointHandler _inner; +//internal sealed class PkceEndpointHandlerBridge : IPkceEndpointHandler +//{ +// private readonly PkceEndpointHandler _inner; - public PkceEndpointHandlerBridge(PkceEndpointHandler inner) - { - _inner = inner; - } +// public PkceEndpointHandlerBridge(PkceEndpointHandler inner) +// { +// _inner = inner; +// } - public Task AuthorizeAsync(HttpContext ctx) => _inner.AuthorizeAsync(ctx); +// public Task AuthorizeAsync(HttpContext ctx) => _inner.AuthorizeAsync(ctx); - public Task CompleteAsync(HttpContext ctx) => _inner.CompleteAsync(ctx); -} +// public Task CompleteAsync(HttpContext ctx) => _inner.CompleteAsync(ctx); +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index ab0c4530..4a499d2d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -1,27 +1,25 @@ using CodeBeam.UltimateAuth.Core.Abstractions; 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.Infrastructure; -using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints; -public sealed class LoginEndpointHandler : ILoginEndpointHandler +public sealed class LoginEndpointHandler : ILoginEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; - private readonly IUAuthFlowService _flowService; + private readonly IUAuthFlowService _flowService; private readonly IClock _clock; private readonly ICredentialResponseWriter _credentialResponseWriter; private readonly IAuthRedirectResolver _redirectResolver; public LoginEndpointHandler( IAuthFlowContextAccessor authFlow, - IUAuthFlowService flowService, + IUAuthFlowService flowService, IClock clock, ICredentialResponseWriter credentialResponseWriter, IAuthRedirectResolver redirectResolver) @@ -68,7 +66,7 @@ public async Task LoginAsync(HttpContext ctx) if (!result.IsSuccess) { - var decisionFailure = _redirectResolver.ResolveFailure(authFlow, ctx, result.FailureReason ?? AuthFailureReason.Unknown); + var decisionFailure = _redirectResolver.ResolveFailure(authFlow, ctx, result.FailureReason ?? AuthFailureReason.Unknown, result); return decisionFailure.Enabled ? Results.Redirect(decisionFailure.TargetUrl!) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index 6106dce2..15d01de7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -9,15 +9,15 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; -public sealed class LogoutEndpointHandler : ILogoutEndpointHandler +public sealed class LogoutEndpointHandler : ILogoutEndpointHandler { private readonly IAuthFlowContextAccessor _authContext; - private readonly IUAuthFlowService _flow; + private readonly IUAuthFlowService _flow; private readonly IClock _clock; private readonly IUAuthCookieManager _cookieManager; private readonly IAuthRedirectResolver _redirectResolver; - public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) + public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index c54c0328..195ae018 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -13,10 +13,10 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; -internal sealed class PkceEndpointHandler : IPkceEndpointHandler +internal sealed class PkceEndpointHandler : IPkceEndpointHandler { private readonly IAuthFlowContextAccessor _authContext; - private readonly IUAuthFlowService _flow; + private readonly IUAuthFlowService _flow; private readonly IAuthStore _authStore; private readonly IPkceAuthorizationValidator _validator; private readonly IClock _clock; @@ -26,7 +26,7 @@ internal sealed class PkceEndpointHandler : IPkceEndpointHandler public PkceEndpointHandler( IAuthFlowContextAccessor authContext, - IUAuthFlowService flow, + IUAuthFlowService flow, IAuthStore authStore, IPkceAuthorizationValidator validator, IClock clock, diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index ac7fa6aa..d4c3df38 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -37,17 +37,16 @@ public async Task RefreshAsync(HttpContext ctx) { var flow = _authContext.Current; - if (flow.Session is not SessionSecurityContext session) + if (flow == null) { - //_logger.LogDebug("Refresh called without active session."); - return Results.Ok(RefreshOutcome.None); + return Results.BadRequest("No AuthFlowContext is found."); } var request = new RefreshFlowRequest { - SessionId = session.SessionId, + SessionId = flow?.Session?.SessionId, RefreshToken = _refreshTokenResolver.Resolve(ctx), - Device = flow.Device, + Device = flow!.Device, Now = DateTimeOffset.UtcNow }; @@ -55,7 +54,6 @@ public async Task RefreshAsync(HttpContext ctx) if (!result.Succeeded) { - WriteRefreshHeader(ctx, flow, RefreshOutcome.ReauthRequired); return Results.Unauthorized(); } @@ -75,15 +73,10 @@ public async Task RefreshAsync(HttpContext ctx) _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); } - WriteRefreshHeader(ctx, flow, result.Outcome); + if (flow.OriginalOptions.Diagnostics.EnableRefreshDetails) + { + _refreshWriter.Write(ctx, result.Outcome); + } return Results.NoContent(); } - - private void WriteRefreshHeader(HttpContext ctx, AuthFlowContext flow, RefreshOutcome outcome) - { - if (!flow.OriginalOptions.Diagnostics.EnableRefreshHeaders) - return; - - _refreshWriter.Write(ctx, outcome); - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index 2495f81f..b8f09be6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -40,8 +40,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Json( new AuthValidationResult { - IsValid = false, - State = "missing" + State = SessionState.NotFound }, statusCode: StatusCodes.Status401Unauthorized ); @@ -54,8 +53,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Json( new AuthValidationResult { - IsValid = false, - State = "invalid" + State = SessionState.Invalid }, statusCode: StatusCodes.Status401Unauthorized ); @@ -78,8 +76,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Json( new AuthValidationResult { - IsValid = false, - State = "invalid" + State = SessionState.Invalid }, statusCode: StatusCodes.Status401Unauthorized ); @@ -89,8 +86,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Ok(new AuthValidationResult { - IsValid = result.IsValid, - State = result.IsValid ? "active" : result.State.ToString().ToLowerInvariant(), + State = result.IsValid ? SessionState.Active : result.State, Snapshot = snapshot }); } @@ -99,8 +95,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Json( new AuthValidationResult { - IsValid = false, - State = "unsupported" + State = SessionState.Unsupported }, statusCode: StatusCodes.Status401Unauthorized ); 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/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 6b35ea70..4c55fe37 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -25,7 +25,7 @@ using CodeBeam.UltimateAuth.Server.Runtime; 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.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -136,7 +136,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); - services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -152,7 +152,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(typeof(ILoginOrchestrator<>), typeof(LoginOrchestrator<>)); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -217,20 +217,20 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddSingleton(); - 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.TryAddScoped(); - services.TryAddScoped>(); - services.TryAddScoped(); + //services.TryAddScoped>(); + services.TryAddScoped(); // ------------------------------ // ASP.NET CORE INTEGRATION @@ -249,6 +249,19 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddAuthorization(); + + services.Configure(opt => + { + opt.AllowedBuiltIns = new HashSet + { + UserIdentifierType.Username, + UserIdentifierType.Email + }; + + opt.EnableCustomResolvers = true; + opt.CustomResolversFirst = true; + }); + return services; } @@ -309,26 +322,6 @@ internal static IServiceCollection AddAuthorizationInternal(IServiceCollection s services.TryAddScoped(typeof(IUserClaimsProvider), typeof(AuthorizationClaimsProvider)); return services; } - - - public static IServiceCollection AddUAuthServerInfrastructure(this IServiceCollection services) - { - // Flow orchestration - services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); - - // Issuers - services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); - services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); - - // Endpoints - services.TryAddSingleton(); - - // Cookie management (default) - services.TryAddSingleton(); - - return services; - } - } internal sealed class NullTenantResolver : ITenantIdResolver diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index 7c011bbd..5efccabe 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -7,6 +7,7 @@ public static class UltimateAuthApplicationBuilderExtensions { public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app) { + app.UseUAuthExceptionHandling(); app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs new file mode 100644 index 00000000..cf9b5520 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -0,0 +1,56 @@ +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 + { + UAuthConflictException => StatusCodes.Status409Conflict, + UAuthValidationException => StatusCodes.Status400BadRequest, + UAuthUnauthorizedException => StatusCodes.Status401Unauthorized, + UAuthForbiddenException => StatusCodes.Status403Forbidden, + UAuthNotFoundException => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs index f8f8eac4..6fac9ccc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs @@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Server.Flows; /// credential validation, user resolution, authority decision, /// and session creation. /// -public interface ILoginOrchestrator +public interface ILoginOrchestrator { Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs index 141c1628..a41344b2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -10,33 +8,26 @@ namespace CodeBeam.UltimateAuth.Server.Flows; /// public sealed class LoginAuthority : ILoginAuthority { - private readonly UAuthLoginOptions _options; - - public LoginAuthority(IOptions options) - { - _options = options.Value.Login; - } - public LoginDecision Decide(LoginDecisionContext context) { if (!context.UserExists || context.UserKey is null) { - return LoginDecision.Deny("Invalid credentials."); + return LoginDecision.Deny(AuthFailureReason.InvalidCredentials); } var state = context.SecurityState; if (state is not null) { if (state.IsLocked) - return LoginDecision.Deny("user_is_locked"); + return LoginDecision.Deny(AuthFailureReason.LockedOut); if (state.RequiresReauthentication) - return LoginDecision.Challenge("reauth_required"); + return LoginDecision.Challenge(AuthFailureReason.ReauthenticationRequired); } if (!context.CredentialsValid) { - return LoginDecision.Deny("Invalid credentials."); + 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 index 68db4061..a04ddc2b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecision.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecision.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Flows; /// /// Represents the outcome of a login decision. @@ -6,20 +8,21 @@ public sealed class LoginDecision { public LoginDecisionKind Kind { get; } - public string? Reason { get; } + public AuthFailureReason? FailureReason { get; } + - private LoginDecision(LoginDecisionKind kind, string? reason = null) + private LoginDecision(LoginDecisionKind kind, AuthFailureReason? reason = null) { Kind = kind; - Reason = reason; + FailureReason = reason; } public static LoginDecision Allow() => new(LoginDecisionKind.Allow); - public static LoginDecision Deny(string reason) + public static LoginDecision Deny(AuthFailureReason reason) => new(LoginDecisionKind.Deny, reason); - public static LoginDecision Challenge(string reason) + public static LoginDecision Challenge(AuthFailureReason reason) => new(LoginDecisionKind.Challenge, reason); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 77488ee0..4486152b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -3,7 +3,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Events; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; @@ -13,37 +12,40 @@ using CodeBeam.UltimateAuth.Users; using Microsoft.Extensions.Options; +// TODO: Identifier-based throttling, Exponential lockout + namespace CodeBeam.UltimateAuth.Server.Flows; -internal sealed class LoginOrchestrator : ILoginOrchestrator +internal sealed class LoginOrchestrator : ILoginOrchestrator { - private readonly ICredentialStore _credentialStore; // authentication + private readonly ILoginIdentifierResolver _identifierResolver; + private readonly ICredentialStore _credentialStore; // authentication private readonly ICredentialValidator _credentialValidator; private readonly IUserRuntimeStateProvider _users; // eligible private readonly ILoginAuthority _authority; private readonly ISessionOrchestrator _sessionOrchestrator; private readonly ITokenIssuer _tokens; private readonly IUserClaimsProvider _claimsProvider; - private readonly IUserIdConverterResolver _userIdConverterResolver; - private readonly IUserSecurityStateWriter _securityWriter; - private readonly IUserSecurityStateProvider _securityStateProvider; // runtime risk + private readonly IUserSecurityStateWriter _securityWriter; + private readonly IUserSecurityStateProvider _securityStateProvider; // runtime risk private readonly UAuthEventDispatcher _events; private readonly UAuthServerOptions _options; public LoginOrchestrator( - ICredentialStore credentialStore, + ILoginIdentifierResolver identifierResolver, + ICredentialStore credentialStore, ICredentialValidator credentialValidator, IUserRuntimeStateProvider users, ILoginAuthority authority, ISessionOrchestrator sessionOrchestrator, ITokenIssuer tokens, IUserClaimsProvider claimsProvider, - IUserIdConverterResolver userIdConverterResolver, - IUserSecurityStateWriter securityWriter, - IUserSecurityStateProvider securityStateProvider, + IUserSecurityStateWriter securityWriter, + IUserSecurityStateProvider securityStateProvider, UAuthEventDispatcher events, IOptions options) { + _identifierResolver = identifierResolver; _credentialStore = credentialStore; _credentialValidator = credentialValidator; _users = users; @@ -51,7 +53,6 @@ public LoginOrchestrator( _sessionOrchestrator = sessionOrchestrator; _tokens = tokens; _claimsProvider = claimsProvider; - _userIdConverterResolver = userIdConverterResolver; _securityWriter = securityWriter; _securityStateProvider = securityStateProvider; _events = events; @@ -63,54 +64,59 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req ct.ThrowIfCancellationRequested(); var now = request.At ?? DateTimeOffset.UtcNow; - var credentials = await _credentialStore.FindByLoginAsync(request.Tenant, request.Identifier, ct); - bool hasCandidateUser = false; - TUserId candidateUserId = default!; - TUserId validatedUserId = default!; + var resolution = await _identifierResolver.ResolveAsync(request.Tenant, request.Identifier, ct); + + var userKey = resolution?.UserKey; + + bool userExists = false; bool credentialsValid = false; - foreach (var credential in credentials.OfType()) - { - if (!credential.Security.IsUsable(now)) - continue; + IUserSecurityState? securityState = null; - var typed = (ICredential)credential; + DateTimeOffset? lockoutUntilUtc = null; + int? remainingAttempts = null; - if (!hasCandidateUser) + if (userKey is not null) + { + var user = await _users.GetAsync(request.Tenant, userKey.Value, ct); + if (user is not null && user.IsActive && !user.IsDeleted) { - candidateUserId = typed.UserId; - hasCandidateUser = true; - } + userExists = true; - var result = await _credentialValidator.ValidateAsync((ICredential)credential, request.Secret, ct); + securityState = await _securityStateProvider.GetAsync(request.Tenant, userKey.Value, ct); - if (result.IsValid) - { - validatedUserId = ((ICredential)credential).UserId; - credentialsValid = true; - break; - } - } + if (securityState?.LastFailedAt is DateTimeOffset lastFail && _options.Login.FailureWindow is { } window && now - lastFail > window) + { + await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); + securityState = null; + } - bool userExists = false; - IUserSecurityState? securityState = null; - UserKey? userKey = null; + if (securityState?.LockedUntil is DateTimeOffset until && until <= now) + { + await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); + securityState = null; + } - if (candidateUserId is not null) - { - securityState = await _securityStateProvider.GetAsync(request.Tenant, candidateUserId, ct); - var converter = _userIdConverterResolver.GetConverter(); - var canonicalUserId = converter.ToCanonicalString(candidateUserId); + if (securityState?.LockedUntil is DateTimeOffset stillLocked && stillLocked > now) + { + return LoginResult.Failed(AuthFailureReason.LockedOut, stillLocked, 0); + } - if (!string.IsNullOrWhiteSpace(canonicalUserId)) - { - var tempUserKey = UserKey.FromString(canonicalUserId); - var user = await _users.GetAsync(request.Tenant, tempUserKey, ct); - if (user is not null && user.IsActive && !user.IsDeleted) + var credentials = await _credentialStore.GetByUserAsync(request.Tenant, userKey.Value, ct); + + foreach (var credential in credentials.OfType()) { - userKey = tempUserKey; - userExists = true; + if (!credential.Security.IsUsable(now)) + continue; + + var result = await _credentialValidator.ValidateAsync((ICredential)credential, request.Secret, ct); + + if (result.IsValid) + { + credentialsValid = true; + break; + } } } } @@ -128,54 +134,70 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req var decision = _authority.Decide(decisionContext); - if (candidateUserId is not null) + var max = _options.Login.MaxFailedAttempts; + + if (decision.Kind == LoginDecisionKind.Deny) { - if (decision.Kind == LoginDecisionKind.Allow) + if (userKey is not null && userExists) { - await _securityWriter.ResetFailuresAsync(request.Tenant, candidateUserId, ct); - } - else - { - var isCurrentlyLocked = securityState?.IsLocked == true && securityState?.LockedUntil is DateTimeOffset until && until > now; + var isCurrentlyLocked = + securityState?.IsLocked == true && + securityState?.LockedUntil is DateTimeOffset until && + until > now; if (!isCurrentlyLocked) { - await _securityWriter.RecordFailedLoginAsync(request.Tenant, candidateUserId, now, ct); + await _securityWriter.RecordFailedLoginAsync(request.Tenant, userKey.Value, now, ct); var currentFailures = securityState?.FailedLoginAttempts ?? 0; var nextCount = currentFailures + 1; - if (_options.Login.MaxFailedAttempts > 0 && nextCount >= _options.Login.MaxFailedAttempts) + if (max > 0) { - var lockedUntil = now.AddMinutes(_options.Login.LockoutMinutes); - await _securityWriter.LockUntilAsync(request.Tenant, candidateUserId, lockedUntil, ct); + if (nextCount >= max) + { + lockoutUntilUtc = now.Add(_options.Login.LockoutDuration); + await _securityWriter.LockUntilAsync(request.Tenant, userKey.Value, lockoutUntilUtc.Value, ct); + remainingAttempts = 0; + + return LoginResult.Failed(AuthFailureReason.LockedOut, lockoutUntilUtc, remainingAttempts); + } + else + { + remainingAttempts = max - nextCount; + } } } - + else + { + lockoutUntilUtc = securityState!.LockedUntil; + remainingAttempts = 0; + } } - } - if (decision.Kind == LoginDecisionKind.Deny) - return LoginResult.Failed(); + return LoginResult.Failed(decision.FailureReason, lockoutUntilUtc, remainingAttempts); + } if (decision.Kind == LoginDecisionKind.Challenge) { return LoginResult.Continue(new LoginContinuation { - Type = LoginContinuationType.Mfa, - Hint = decision.Reason + Type = LoginContinuationType.Mfa }); } - if (validatedUserId is null || userKey is not UserKey validUserKey) - return LoginResult.Failed(); + if (!credentialsValid || userKey is null) + return LoginResult.Failed(AuthFailureReason.InvalidCredentials); - var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, validUserKey, ct); + // After this point, the login is successful. We can reset any failure counts and proceed to create a session. + await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); + + var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, userKey.Value, ct); var sessionContext = new AuthenticatedSessionContext { Tenant = request.Tenant, - UserKey = validUserKey, + UserKey = userKey.Value, Now = now, Device = request.Device, Claims = claims, @@ -185,7 +207,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req }; var authContext = flow.ToAuthContext(now); - var issuedSession = await _sessionOrchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); + var issuedSession = await _sessionOrchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); AuthTokens? tokens = null; @@ -194,7 +216,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req var tokenContext = new TokenIssuanceContext { Tenant = request.Tenant, - UserKey = validUserKey, + UserKey = userKey.Value, SessionId = issuedSession.Session.SessionId, ChainId = request.ChainId, Claims = claims.AsDictionary() @@ -207,9 +229,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req }; } - await _events.DispatchAsync(new UserLoggedInContext(request.Tenant, validUserKey, now, request.Device, issuedSession.Session.SessionId)); + await _events.DispatchAsync( + new UserLoggedInContext(request.Tenant, userKey.Value, now, request.Device, issuedSession.Session.SessionId)); return LoginResult.Success(issuedSession.Session.SessionId, tokens); - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs index 1293f243..8ff47bc9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs @@ -17,7 +17,7 @@ public RefreshResponseWriter(IOptions options) public void Write(HttpContext context, RefreshOutcome outcome) { - if (!_diagnostics.EnableRefreshHeaders) + if (!_diagnostics.EnableRefreshDetails) return; context.Response.Headers[UAuthConstants.Headers.Refresh] = outcome switch diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs index cd44daf7..795307ce 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand +internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand { public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs index 20c98728..f137f88e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs @@ -1,7 +1,13 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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; @@ -14,20 +20,26 @@ 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) - => Resolve(flow, ctx, flow.Response.Redirect.FailurePath, reason); + 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) + 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 (redirect.AllowReturnUrlOverride && flow.ReturnUrlInfo is { } info) + if (failureReason is null && redirect.AllowReturnUrlOverride && flow.ReturnUrlInfo is { } info) { if (info.IsAbsolute && (info.AbsoluteUri!.Scheme == Uri.UriSchemeHttp || info.AbsoluteUri!.Scheme == Uri.UriSchemeHttps)) { @@ -43,29 +55,44 @@ private RedirectDecision Resolve(AuthFlowContext flow, HttpContext ctx, string? } } - if (!string.IsNullOrWhiteSpace(fallbackPath)) - { - var baseAddress = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); + if (string.IsNullOrWhiteSpace(fallbackPath)) + return RedirectDecision.None(); + + var baseUrl = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); - IDictionary? query = null; + var query = new Dictionary(); - if (failureReason is not null) + if (!string.IsNullOrWhiteSpace(flow.ReturnUrlInfo?.RelativePath)) + query["returnUrl"] = flow.ReturnUrlInfo.RelativePath; + + // Failure payload + if (failureReason is not null) + { + var payload = new AuthFlowPayload { - var code = redirect.FailureCodes != null && - redirect.FailureCodes.TryGetValue(failureReason.Value, out var mapped) - ? mapped - : "failed"; + V = 1, + Flow = flow.FlowType, + Status = "failed", + Reason = failureReason + }; - query = new Dictionary + if (flow.FlowType == AuthFlowType.Login && loginResult is not null && flow.OriginalOptions.Login.IncludeFailureDetails) + { + payload = payload with { - [redirect.FailureQueryKey ?? "error"] = code + LockoutUntil = loginResult.LockoutUntilUtc?.ToUnixTimeSeconds(), + RemainingAttempts = loginResult.RemainingAttempts }; } - return RedirectDecision.To(UrlComposer.Combine(baseAddress, fallbackPath, query)); + var json = JsonSerializer.Serialize(payload, PayloadJsonOptions); + + var encoded = Base64UrlTextEncoder.Encode(Encoding.UTF8.GetBytes(json)); + + query["uauth"] = encoded; } - return RedirectDecision.None(); + return RedirectDecision.To(UrlComposer.Combine(baseUrl, fallbackPath, query)); } private static void ValidateAllowed(string baseAddress, UAuthServerOptions options) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs index 55c789ca..581b187c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; using Microsoft.AspNetCore.Http; @@ -7,5 +8,5 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IAuthRedirectResolver { RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext context); - RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext context, AuthFailureReason reason); + RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext context, AuthFailureReason reason, LoginResult? loginResult = null); } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs index d9aafb6d..b2fa0ef9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs @@ -19,6 +19,9 @@ public sealed class LoginRedirectOptions /// public bool AllowReturnUrlOverride { get; set; } = true; + public bool IncludeLockoutTiming { get; set; } = true; + public bool IncludeRemainingAttempts { get; set; } = false; + internal LoginRedirectOptions Clone() => new() { RedirectEnabled = RedirectEnabled, @@ -27,6 +30,8 @@ public sealed class LoginRedirectOptions FailureQueryKey = FailureQueryKey, CodeQueryKey = CodeQueryKey, FailureCodes = new Dictionary(FailureCodes), - AllowReturnUrlOverride = AllowReturnUrlOverride + AllowReturnUrlOverride = AllowReturnUrlOverride, + IncludeLockoutTiming = IncludeLockoutTiming, + IncludeRemainingAttempts = IncludeRemainingAttempts }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs index af530528..46664b14 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs @@ -3,13 +3,13 @@ public sealed class UAuthDiagnosticsOptions { /// - /// Enables debug / sample-only response headers such as X-UAuth-Refresh. - /// Should be disabled in production. + /// 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 EnableRefreshHeaders { get; set; } = false; + public bool EnableRefreshDetails { get; set; } = false; internal UAuthDiagnosticsOptions Clone() => new() { - EnableRefreshHeaders = EnableRefreshHeaders + EnableRefreshDetails = EnableRefreshDetails }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs new file mode 100644 index 00000000..450ce64e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Options; +public sealed class UAuthLoginIdentifierOptions +{ + public ISet AllowedBuiltIns { 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; + + internal UAuthLoginIdentifierOptions Clone() => new() + { + AllowedBuiltIns = new HashSet(AllowedBuiltIns), + RequireVerificationForEmail = RequireVerificationForEmail, + RequireVerificationForPhone = RequireVerificationForPhone, + EnableCustomResolvers = EnableCustomResolvers, + CustomResolversFirst = CustomResolversFirst, + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index ed587649..17dbebe1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -89,6 +89,8 @@ public sealed class UAuthServerOptions public UAuthUserIdentifierOptions UserIdentifiers { get; set; } = new(); + public UAuthLoginIdentifierOptions LoginIdentifiers { get; set; } = new(); + public UAuthNavigationOptions Navigation { get; set; } = new(); @@ -139,6 +141,7 @@ internal UAuthServerOptions Clone() Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), UserIdentifiers = UserIdentifiers.Clone(), + LoginIdentifiers = LoginIdentifiers.Clone(), Endpoints = Endpoints.Clone(), Navigation = Navigation.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs index 0429af6f..fd995ac8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs @@ -7,6 +7,7 @@ public sealed class UAuthUserIdentifierOptions 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; @@ -19,6 +20,7 @@ public sealed class UAuthUserIdentifierOptions AllowMultipleUsernames = AllowMultipleUsernames, AllowMultipleEmail = AllowMultipleEmail, AllowMultiplePhone = AllowMultiplePhone, + RequireUsernameIdentifier = RequireUsernameIdentifier, RequireEmailVerification = RequireEmailVerification, RequirePhoneVerification = RequirePhoneVerification, AllowAdminOverride = AllowAdminOverride, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs index 450b7bbd..962cb852 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs @@ -12,7 +12,7 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) if (login.MaxFailedAttempts < 0) errors.Add("Login.MaxFailedAttempts cannot be negative."); - if (login.LockoutMinutes < 0) + if (login.LockoutDuration < TimeSpan.Zero) errors.Add("Login.LockoutMinutes cannot be negative."); return errors.Count == 0 diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs index 740c7b66..b327dfe4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs @@ -13,5 +13,5 @@ public sealed class UAuthServerProductInfo public UAuthHubDeploymentMode HubDeploymentMode { get; init; } public bool MultiTenancyEnabled { get; init; } - public string FrameworkDescription { get; set; } + public string? FrameworkDescription { get; set; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs index 036c5481..54924e2a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Server.Services; /// Handles authentication flows such as login, /// logout, session refresh and reauthentication. /// -public interface IUAuthFlowService +public interface IUAuthFlowService { Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index aebf9590..1ad74409 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -9,18 +9,18 @@ namespace CodeBeam.UltimateAuth.Server.Services; -internal sealed class UAuthFlowService : IUAuthFlowService +internal sealed class UAuthFlowService : IUAuthFlowService { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAuthFlowContextFactory _authFlowContextFactory; - private readonly ILoginOrchestrator _loginOrchestrator; + private readonly ILoginOrchestrator _loginOrchestrator; private readonly ISessionOrchestrator _orchestrator; private readonly UAuthEventDispatcher _events; public UAuthFlowService( IAuthFlowContextAccessor authFlow, IAuthFlowContextFactory authFlowContextFactory, - ILoginOrchestrator loginOrchestrator, + ILoginOrchestrator loginOrchestrator, ISessionOrchestrator orchestrator, UAuthEventDispatcher events) { diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index e8a51f72..74abecdc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -23,6 +23,7 @@ public UAuthSessionValidator( _options = options.Value; } + // TODO: Improve Device binding // Validate runs before AuthFlowContext is set, do not call _authFlow here. public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs index 9d01e8a2..e236ba27 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -10,9 +10,12 @@ public sealed record CredentialDto public DateTimeOffset? LastUsedAt { get; init; } - public DateTimeOffset? RestrictedUntil { get; init; } + public DateTimeOffset? LockedUntil { get; init; } public DateTimeOffset? ExpiresAt { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + public DateTimeOffset? ResetRequestedAt { get; init; } + public string? Source { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs index a3fed36e..e4e10c5c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs @@ -2,7 +2,6 @@ public sealed record CredentialMetadata { - public DateTimeOffset CreatedAt { get; init; } 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 index 5e197c4a..2291404c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -2,44 +2,103 @@ public sealed class CredentialSecurityState { - public CredentialSecurityStatus Status { get; } - public DateTimeOffset? RestrictedUntil { get; } + public DateTimeOffset? RevokedAt { get; } + public DateTimeOffset? LockedUntil { get; } public DateTimeOffset? ExpiresAt { get; } - public string? Reason { get; } + public DateTimeOffset? ResetRequestedAt { get; init; } + public Guid SecurityStamp { get; } + + public CredentialSecurityStatus Status(DateTimeOffset now) + { + if (RevokedAt is not null) + return CredentialSecurityStatus.Revoked; + + if (LockedUntil is not null && LockedUntil > now) + return CredentialSecurityStatus.Locked; + + if (ExpiresAt is not null && ExpiresAt <= now) + return CredentialSecurityStatus.Expired; + + if (ResetRequestedAt is not null) + return CredentialSecurityStatus.ResetRequested; + + return CredentialSecurityStatus.Active; + } public CredentialSecurityState( - CredentialSecurityStatus status, - DateTimeOffset? restrictedUntil = null, + DateTimeOffset? revokedAt = null, + DateTimeOffset? lockedUntil = null, DateTimeOffset? expiresAt = null, - string? reason = null) + DateTimeOffset? resetRequestedAt = null, + Guid securityStamp = default) { - Status = status; - RestrictedUntil = restrictedUntil; + RevokedAt = revokedAt; + LockedUntil = lockedUntil; ExpiresAt = expiresAt; - Reason = reason; + ResetRequestedAt = resetRequestedAt; + SecurityStamp = securityStamp; } - public static CredentialSecurityState Active { get; } = new(CredentialSecurityStatus.Active); - /// /// Determines whether the credential can be used at the given time. /// - public bool IsUsable(DateTimeOffset now) + public bool IsUsable(DateTimeOffset now) => Status(now) == CredentialSecurityStatus.Active; + + public static CredentialSecurityState Active(Guid? securityStamp = null) { - if (Status == CredentialSecurityStatus.Expired) - return false; + return new CredentialSecurityState( + revokedAt: null, + lockedUntil: null, + expiresAt: null, + resetRequestedAt: null, + securityStamp: securityStamp ?? Guid.NewGuid()); + } - if (ExpiresAt is not null && ExpiresAt <= now) - return false; + public CredentialSecurityState Revoke(DateTimeOffset now) + { + return new CredentialSecurityState( + revokedAt: now, + lockedUntil: LockedUntil, + expiresAt: ExpiresAt, + resetRequestedAt: ResetRequestedAt, + securityStamp: Guid.NewGuid()); + } - if ((Status == CredentialSecurityStatus.Locked || Status == CredentialSecurityStatus.Revoked) && RestrictedUntil is not null) - { - return RestrictedUntil <= now; - } + public CredentialSecurityState SetExpiry(DateTimeOffset? expiresAt) + { + return new CredentialSecurityState( + revokedAt: RevokedAt, + lockedUntil: LockedUntil, + expiresAt: expiresAt, + resetRequestedAt: ResetRequestedAt, + securityStamp: SecurityStamp); + } - if (Status == CredentialSecurityStatus.Locked || Status == CredentialSecurityStatus.Revoked) - return false; + public CredentialSecurityState BeginReset(DateTimeOffset now, bool rotateStamp = true) + => new( + revokedAt: RevokedAt, + lockedUntil: LockedUntil, + expiresAt: ExpiresAt, + resetRequestedAt: now, + securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp + ); - return true; + public CredentialSecurityState CompleteReset(bool rotateStamp = true) + => new( + revokedAt: RevokedAt, + lockedUntil: LockedUntil, + expiresAt: ExpiresAt, + resetRequestedAt: null, + securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp + ); + + public CredentialSecurityState RotateStamp() + { + return new CredentialSecurityState( + revokedAt: RevokedAt, + lockedUntil: LockedUntil, + expiresAt: ExpiresAt, + resetRequestedAt: ResetRequestedAt, + 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 index 24838b72..9f122c09 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs @@ -8,5 +8,4 @@ public enum CredentialSecurityStatus Locked = 20, Expired = 30, ResetRequested = 40, - ResetRequired = 50 } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index 6d51c4f1..2598739f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -11,11 +11,11 @@ internal sealed class InMemoryCredentialSeedContributor : ISeedContributor { public int Order => 10; - private readonly ICredentialStore _credentials; + private readonly ICredentialStore _credentials; private readonly IInMemoryUserIdProvider _ids; private readonly IUAuthPasswordHasher _hasher; - public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) + public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) { _credentials = credentials; _ids = ids; @@ -24,22 +24,25 @@ public InMemoryCredentialSeedContributor(ICredentialStore credentials, public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { - await SeedCredentialAsync("admin", _ids.GetAdminUserId(), tenant, ct); - await SeedCredentialAsync("user", _ids.GetUserUserId(), tenant, ct); + await SeedCredentialAsync(_ids.GetAdminUserId(), "admin", tenant, ct); + await SeedCredentialAsync(_ids.GetUserUserId(), "user", tenant, ct); } - private async Task SeedCredentialAsync(string login, UserKey userKey, TenantKey tenant, CancellationToken ct) + private async Task SeedCredentialAsync(UserKey userKey, string hash, TenantKey tenant, CancellationToken ct) { - if (await _credentials.ExistsAsync(tenant, userKey, CredentialType.Password, ct)) + if (await _credentials.ExistsAsync(tenant, userKey, CredentialType.Password, null, ct)) return; await _credentials.AddAsync(tenant, - new PasswordCredential( + new PasswordCredential( + Guid.NewGuid(), + tenant, userKey, - login, - _hasher.Hash(login), - CredentialSecurityState.Active, - new CredentialMetadata { CreatedAt = DateTimeOffset.Now}), + _hasher.Hash(hash), + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow, + null), ct); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 66d3d7bf..263c365b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; @@ -7,170 +8,170 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory; -internal sealed class InMemoryCredentialStore : ICredentialStore, ICredentialSecretStore where TUserId : notnull +internal sealed class InMemoryCredentialStore : ICredentialStore { - private readonly ConcurrentDictionary<(TenantKey Tenant, string Login), InMemoryPasswordCredentialState> _byLogin; - private readonly ConcurrentDictionary<(TenantKey Tenant, TUserId UserId), List>> _byUser; + private readonly ConcurrentDictionary<(TenantKey Tenant, Guid Id), PasswordCredential> _byId = new(); + private readonly ConcurrentDictionary<(TenantKey Tenant, UserKey UserKey), ConcurrentDictionary> _byUser = new(); private readonly IUAuthPasswordHasher _hasher; - private readonly IInMemoryUserIdProvider _userIdProvider; - public InMemoryCredentialStore(IUAuthPasswordHasher hasher, IInMemoryUserIdProvider userIdProvider) + public InMemoryCredentialStore(IUAuthPasswordHasher hasher) { _hasher = hasher; - _userIdProvider = userIdProvider; - - _byLogin = new ConcurrentDictionary<(TenantKey, string), InMemoryPasswordCredentialState>(); - _byUser = new ConcurrentDictionary<(TenantKey, TUserId), List>>(); } - public Task>> FindByLoginAsync(TenantKey tenant, string loginIdentifier, CancellationToken ct = default) + public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byLogin.TryGetValue((tenant, loginIdentifier), out var state)) - return Task.FromResult>>(Array.Empty>()); + if (!_byUser.TryGetValue((tenant, userKey), out var ids) || ids.Count == 0) + return Task.FromResult>(Array.Empty()); + + var list = new List(ids.Count); - return Task.FromResult>>(new[] { Map(state) }); + foreach (var id in ids.Keys) + { + if (_byId.TryGetValue((tenant, id), out var cred)) + { + list.Add(cred); + } + } + + return Task.FromResult>(list); } - public Task>> GetByUserAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) + public Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenant, userId), out var list)) - return Task.FromResult>>(Array.Empty>()); - - return Task.FromResult>>(list.Select(Map).ToArray()); + _byId.TryGetValue((tenant, credentialId), out var cred); + return Task.FromResult(cred); } - public Task>> GetByUserAndTypeAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default) + public Task ExistsAsync(TenantKey tenant, UserKey userKey, CredentialType type, string? secretHash, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenant, userId), out var list)) - return Task.FromResult>>(Array.Empty>()); + if (!_byUser.TryGetValue((tenant, userKey), out var ids) || ids.Count == 0) + return Task.FromResult(false); - return Task.FromResult>>( - list.Where(c => c.Type == type) - .Select(Map) - .ToArray()); - } + foreach (var id in ids.Keys) + { + if (!_byId.TryGetValue((tenant, id), out var cred)) + continue; - public Task ExistsAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + if (cred.Type != type) + continue; + + if (secretHash is null) + return Task.FromResult(true); - return Task.FromResult(_byUser.TryGetValue((tenant, userId), out var list) && list.Any(c => c.Type == type)); + if (string.Equals(cred.SecretHash, secretHash, StringComparison.Ordinal)) + return Task.FromResult(true); + } + + return Task.FromResult(false); } - public Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) + public Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (credential is not PasswordCredential pwd) + // TODO: Support other credential types if needed. For now, we only have PasswordCredential in-memory. + if (credential is not PasswordCredential pwd) throw new NotSupportedException("Only password credentials are supported in-memory."); - var state = new InMemoryPasswordCredentialState - { - UserId = pwd.UserId, - Login = pwd.LoginIdentifier, - SecretHash = pwd.SecretHash, - Security = pwd.Security, - Metadata = pwd.Metadata - }; - - _byLogin[(tenant, pwd.LoginIdentifier)] = state; - - _byUser.AddOrUpdate( - (tenant, pwd.UserId), - _ => new List> { state }, - (_, list) => - { - list.Add(state); - return list; - }); + var id = pwd.Id == Guid.Empty ? Guid.NewGuid() : pwd.Id; - return Task.CompletedTask; - } + var key = (tenant, id); + if (_byId.ContainsKey(key)) + throw new InvalidOperationException("credential_already_exists"); - public Task UpdateSecurityStateAsync(TenantKey tenant, TUserId userId, CredentialType type, CredentialSecurityState securityState, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + if (pwd.Id == Guid.Empty) + throw new InvalidOperationException("credential_id_required"); - if (_byUser.TryGetValue((tenant, userId), out var list)) - { - var state = list.FirstOrDefault(c => c.Type == type); - if (state != null) - state.Security = securityState; - } + if (!_byId.TryAdd(key, pwd)) + throw new InvalidOperationException("credential_already_exists"); + + var userIndex = _byUser.GetOrAdd((tenant, pwd.UserKey), _ => new ConcurrentDictionary()); + userIndex.TryAdd(pwd.Id, 0); return Task.CompletedTask; } - public Task UpdateMetadataAsync(TenantKey tenant, TUserId userId, CredentialType type, CredentialMetadata metadata, CancellationToken ct = default) + public Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue((tenant, userId), out var list)) - { - var state = list.FirstOrDefault(c => c.Type == type); - if (state != null) - state.Metadata = metadata; - } + if (credential is not PasswordCredential pwd) + throw new NotSupportedException("Only password credentials are supported in-memory."); + + var key = (tenant, pwd.Id); + + if (!_byId.ContainsKey(key)) + throw new InvalidOperationException("credential_not_found"); + + _byId[key] = pwd; return Task.CompletedTask; } - public Task SetAsync(TenantKey tenant, TUserId userId, CredentialType type, string secretHash, CancellationToken ct = default) + public Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue((tenant, userId), out var list)) - { - var state = list.FirstOrDefault(c => c.Type == type); - if (state != null) - state.SecretHash = secretHash; - } + var key = (tenant, credentialId); + + if (!_byId.TryGetValue(key, out var cred)) + throw new InvalidOperationException("credential_not_found"); + + if (cred.IsRevoked) + return Task.CompletedTask; + + cred.Revoke(revokedAt); return Task.CompletedTask; } - public Task DeleteAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default) + public Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue((tenant, userId), out var list)) + var key = (tenant, credentialId); + + if (!_byId.TryGetValue(key, out var cred)) + return Task.CompletedTask; + + if (mode == DeleteMode.Hard) { - var state = list.FirstOrDefault(c => c.Type == type); - if (state != null) + _byId.TryRemove(key, out _); + + if (_byUser.TryGetValue((tenant, cred.UserKey), out var set)) { - list.Remove(state); - _byLogin.TryRemove((tenant, state.Login), out _); + set.TryRemove(credentialId, out _); } + + return Task.CompletedTask; } + if (!cred.IsRevoked) + cred.Revoke(now); + return Task.CompletedTask; } - public Task DeleteByUserAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) + public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_byUser.TryRemove((tenant, userId), out var list)) + if (!_byUser.TryGetValue((tenant, userKey), out var ids)) + return Task.CompletedTask; + + foreach (var id in ids.Keys.ToList()) { - foreach (var credential in list) - _byLogin.TryRemove((tenant, credential.Login), out _); + DeleteAsync(tenant, id, mode, now, ct); } return Task.CompletedTask; } - - private static PasswordCredential Map(InMemoryPasswordCredentialState state) - => new( - userId: state.UserId, - loginIdentifier: state.Login, - secretHash: state.SecretHash, - security: state.Security, - metadata: state.Metadata); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs index b9da6751..37be6cb4 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs @@ -1,13 +1,13 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials.InMemory; -internal sealed class InMemoryPasswordCredentialState +internal sealed class InMemoryPasswordCredentialState { - public TUserId UserId { get; init; } = default!; + public UserKey UserKey { get; init; } = default!; public CredentialType Type { get; } = CredentialType.Password; - public string Login { get; init; } = default!; public string SecretHash { get; set; } = default!; public CredentialSecurityState Security { get; set; } = default!; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs index 8385ec31..c078aca1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -8,9 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) { - services.TryAddScoped(typeof(InMemoryCredentialStore<>)); - services.TryAddSingleton(typeof(ICredentialStore<>), typeof(InMemoryCredentialStore<>)); - services.TryAddSingleton(typeof(ICredentialSecretStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddScoped(); + services.TryAddSingleton(); // Never try add seed services.AddSingleton(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 59aec6ca..702eb1d5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -1,29 +1,78 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class PasswordCredential : ILoginCredential, ISecretCredential, ISecurableCredential, ICredentialDescriptor +public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor { - public TUserId UserId { get; } + public Guid Id { get; init; } + public TenantKey Tenant { get; init; } + public UserKey UserKey { get; init; } public CredentialType Type => CredentialType.Password; - public string LoginIdentifier { get; } - public string SecretHash { get; } + public string SecretHash { get; private set; } - public CredentialSecurityState Security { get; } - public CredentialMetadata Metadata { get; } + public CredentialSecurityState Security { get; private set; } + public CredentialMetadata Metadata { get; private set; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; private set; } + + + public bool IsRevoked => Security.RevokedAt is not null; + public bool IsExpired(DateTimeOffset now) => Security.ExpiresAt is not null && Security.ExpiresAt <= now; public PasswordCredential( - TUserId userId, - string loginIdentifier, + Guid? id, + TenantKey tenant, + UserKey userKey, string secretHash, CredentialSecurityState security, - CredentialMetadata metadata) + CredentialMetadata metadata, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt) { - UserId = userId; - LoginIdentifier = loginIdentifier; + Id = id ?? Guid.NewGuid(); + Tenant = tenant; + UserKey = userKey; SecretHash = secretHash; Security = security; Metadata = metadata; + CreatedAt = createdAt; + UpdatedAt = updatedAt; + } + + public void ChangeSecret(string newSecretHash, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(newSecretHash)) + throw new ArgumentException("Secret hash cannot be empty.", nameof(newSecretHash)); + + if (IsRevoked) + throw new InvalidOperationException("Cannot change secret of a revoked credential."); + + SecretHash = newSecretHash; + UpdatedAt = now; + Security = Security.RotateStamp(); + } + + public void SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) + { + Security = Security.SetExpiry(expiresAt); + UpdatedAt = now; + } + + public void UpdateSecurity(CredentialSecurityState security, DateTimeOffset now) + { + Security = security; + UpdatedAt = now; + } + + public void Revoke(DateTimeOffset now) + { + if (IsRevoked) + return; + Security = Security.Revoke(now); + UpdatedAt = now; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs new file mode 100644 index 00000000..f356db6c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal static class PasswordCredentialFactory +{ + public static PasswordCredential Create(TenantKey tenant, UserKey userKey, string secretHash, string? source, DateTimeOffset now) + => new PasswordCredential( + id: Guid.NewGuid(), + tenant: tenant, + userKey: userKey, + secretHash: secretHash, + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata { Source = source }, + createdAt: now, + updatedAt: null); +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index 992ef573..e1d17fcc 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -10,11 +10,11 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegration { - private readonly ICredentialStore _credentialStore; + private readonly ICredentialStore _credentialStore; private readonly IUAuthPasswordHasher _passwordHasher; private readonly IClock _clock; - public PasswordUserLifecycleIntegration(ICredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) + public PasswordUserLifecycleIntegration(ICredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) { _credentialStore = credentialStore; _passwordHasher = passwordHasher; @@ -31,18 +31,21 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r var hash = _passwordHasher.Hash(r.Password); - var credential = new PasswordCredential( - userId: userKey, - loginIdentifier: r.PrimaryIdentifierValue!, + var credential = new PasswordCredential( + id: null, + tenant: tenant, + userKey: userKey, secretHash: hash, - security: new CredentialSecurityState(CredentialSecurityStatus.Active, null, null, null), - metadata: new CredentialMetadata { CreatedAt = _clock.UtcNow, LastUsedAt = _clock.UtcNow }); + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata { LastUsedAt = _clock.UtcNow }, + _clock.UtcNow, + null); await _credentialStore.AddAsync(tenant, credential, ct); } public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) { - await _credentialStore.DeleteByUserAsync(tenant, userKey, ct); + await _credentialStore.DeleteByUserAsync(tenant, userKey, mode, _clock.UtcNow, ct); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs index 02a12085..d74955b5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs @@ -11,21 +11,18 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class UserCredentialsService : IUserCredentialsService, IUserCredentialsInternalService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly ICredentialStore _credentials; - private readonly ICredentialSecretStore _secrets; + private readonly ICredentialStore _credentials; private readonly IUAuthPasswordHasher _hasher; private readonly IClock _clock; public UserCredentialsService( IAccessOrchestrator accessOrchestrator, - ICredentialStore credentials, - ICredentialSecretStore secrets, + ICredentialStore credentials, IUAuthPasswordHasher hasher, IClock clock) { _accessOrchestrator = accessOrchestrator; _credentials = credentials; - _secrets = secrets; _hasher = hasher; _clock = clock; } @@ -46,11 +43,12 @@ public async Task GetAllAsync(AccessContext context, Cance .OfType() .Select(c => new CredentialDto { Type = c.Type, - Status = c.Security.Status, - CreatedAt = c.Metadata.CreatedAt, + Status = c.Security.Status(_clock.UtcNow), LastUsedAt = c.Metadata.LastUsedAt, - RestrictedUntil = c.Security.RestrictedUntil, + LockedUntil = c.Security.LockedUntil, ExpiresAt = c.Security.ExpiresAt, + RevokedAt = c.Security.RevokedAt, + ResetRequestedAt = c.Security.ResetRequestedAt, Source = c.Metadata.Source}) .ToArray(); @@ -63,82 +61,89 @@ public async Task GetAllAsync(AccessContext context, Cance return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - // ---------------- ADD ---------------- - public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new AddCredentialCommand( - async innerCt => - { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); + var cmd = new AddCredentialCommand(async innerCt => + { + var userKey = EnsureActor(context); + var now = _clock.UtcNow; - var exists = await _credentials.ExistsAsync(context.ResourceTenant, userKey, request.Type, innerCt); + var alreadyHasType = (await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt)) + .OfType() + .Any(c => c.Type == request.Type); - if (exists) - return AddCredentialResult.Fail("credential_already_exists"); + if (alreadyHasType) + return AddCredentialResult.Fail("credential_already_exists"); - var hash = _hasher.Hash(request.Secret); + var hash = _hasher.Hash(request.Secret); - var credential = new PasswordCredential( - userId: userKey, - loginIdentifier: userKey.Value, - secretHash: hash, - security: new CredentialSecurityState(CredentialSecurityStatus.Active), - metadata: new CredentialMetadata { CreatedAt = _clock.UtcNow, Source = request.Source }); + var credential = PasswordCredentialFactory.Create( + tenant: context.ResourceTenant, + userKey: userKey, + secretHash: hash, + source: request.Source, + now: now); - await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); + await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); - return AddCredentialResult.Success(request.Type); - }); + return AddCredentialResult.Success(request.Type); + }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - // ---------------- CHANGE ---------------- public async Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new ChangeCredentialCommand( - async innerCt => - { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); + var cmd = new ChangeCredentialCommand(async innerCt => + { + var userKey = EnsureActor(context); + var now = _clock.UtcNow; + var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); + if (cred is null) + return ChangeCredentialResult.Fail("credential_not_found"); + + if (cred is PasswordCredential pwd) + { var hash = _hasher.Hash(request.NewSecret); + pwd.ChangeSecret(hash, now); + await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); + } + else + { + return ChangeCredentialResult.Fail("credential_type_unsupported"); + } - await _secrets.SetAsync(context.ResourceTenant, userKey, type, hash, innerCt); - return ChangeCredentialResult.Success(type); - }); + return ChangeCredentialResult.Success(type); + }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } + // ---------------- REVOKE ---------------- public async Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new RevokeCredentialCommand( - async innerCt => - { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); + var cmd = new RevokeCredentialCommand(async innerCt => + { + var userKey = EnsureActor(context); + var now = _clock.UtcNow; - var security = new CredentialSecurityState( - CredentialSecurityStatus.Revoked, - restrictedUntil: request.Until, - expiresAt: null, - reason: request.Reason); + var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); + if (cred is null) + return CredentialActionResult.Fail("credential_not_found"); - await _credentials.UpdateSecurityStateAsync(context.ResourceTenant, userKey, type, security, innerCt); - return CredentialActionResult.Success(); - }); + await _credentials.RevokeAsync(context.ResourceTenant, GetId(cred), now, innerCt); + return CredentialActionResult.Success(); + }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } @@ -147,17 +152,24 @@ public async Task ActivateAsync(AccessContext context, C { ct.ThrowIfCancellationRequested(); - var cmd = new ActivateCredentialCommand( - async innerCt => - { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); + var cmd = new ActivateCredentialCommand(async innerCt => + { + var userKey = EnsureActor(context); + var now = _clock.UtcNow; - var security = new CredentialSecurityState(CredentialSecurityStatus.Active); - await _credentials.UpdateSecurityStateAsync(context.ResourceTenant, userKey, type, security, innerCt); + var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); + if (cred is null) + return CredentialActionResult.Fail("credential_not_found"); + if (cred is ICredentialDescriptor desc && cred is PasswordCredential pwd) + { + pwd.UpdateSecurity(CredentialSecurityState.Active(pwd.Security.SecurityStamp), now); + await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); return CredentialActionResult.Success(); - }); + } + + return CredentialActionResult.Fail("credential_type_unsupported"); + }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } @@ -166,13 +178,21 @@ public async Task BeginResetAsync(AccessContext context, CredentialType type, Be { var cmd = new BeginCredentialResetCommand(async innerCt => { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); + var userKey = EnsureActor(context); + var now = _clock.UtcNow; - var security = new CredentialSecurityState(CredentialSecurityStatus.ResetRequested, reason: request.Reason); + var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); + if (cred is null) + return CredentialActionResult.Fail("credential_not_found"); - await _credentials.UpdateSecurityStateAsync(context.ResourceTenant, userKey, type, security, innerCt); - return CredentialActionResult.Success(); + if (cred is PasswordCredential pwd) + { + pwd.UpdateSecurity(pwd.Security.BeginReset(now), now); + await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); + return CredentialActionResult.Success(); + } + + return CredentialActionResult.Fail("credential_type_unsupported"); }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -182,16 +202,24 @@ public async Task CompleteResetAsync(AccessContext context, CredentialType type, { var cmd = new CompleteCredentialResetCommand(async innerCt => { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); + var userKey = EnsureActor(context); + var now = _clock.UtcNow; - var hash = _hasher.Hash(request.NewSecret); + var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); + if (cred is null) + return CredentialActionResult.Fail("credential_not_found"); - await _secrets.SetAsync(context.ResourceTenant, userKey, type, hash, innerCt); + if (cred is PasswordCredential pwd) + { + var hash = _hasher.Hash(request.NewSecret); + pwd.ChangeSecret(hash, now); + pwd.UpdateSecurity(pwd.Security.CompleteReset(), now); - var security = new CredentialSecurityState(CredentialSecurityStatus.Active); - await _credentials.UpdateSecurityStateAsync(context.ResourceTenant, userKey, type, security, innerCt); - return CredentialActionResult.Success(); + await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); + return CredentialActionResult.Success(); + } + + return CredentialActionResult.Fail("credential_type_unsupported"); }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -201,15 +229,24 @@ public async Task DeleteAsync(AccessContext context, Cre { ct.ThrowIfCancellationRequested(); - var cmd = new DeleteCredentialCommand( - async innerCt => - { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); + var cmd = new DeleteCredentialCommand(async innerCt => + { + var userKey = EnsureActor(context); + var now = _clock.UtcNow; - await _credentials.DeleteAsync(context.ResourceTenant, userKey, type, innerCt); - return CredentialActionResult.Success(); - }); + var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); + if (cred is null) + return CredentialActionResult.Fail("credential_not_found"); + + await _credentials.DeleteAsync( + tenant: context.ResourceTenant, + credentialId: GetId(cred), + mode: DeleteMode.Soft, + now: now, + ct: innerCt); + + return CredentialActionResult.Success(); + }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } @@ -221,7 +258,27 @@ async Task IUserCredentialsInternalService.DeleteInterna { ct.ThrowIfCancellationRequested(); - await _credentials.DeleteByUserAsync(tenant, userKey, ct); + await _credentials.DeleteByUserAsync(tenant, userKey, DeleteMode.Soft, _clock.UtcNow, ct); return CredentialActionResult.Success(); } + + + private static UserKey EnsureActor(AccessContext context) + => context.ActorUserKey is UserKey uk ? uk : throw new UnauthorizedAccessException(); + + private static Guid GetId(ICredential c) + => c switch + { + PasswordCredential p => p.Id, + _ => throw new NotSupportedException("credential_id_missing") + }; + + private async Task GetSingleByTypeAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct) + { + var creds = await _credentials.GetByUserAsync(tenant, userKey, ct); + + var found = creds.OfType().FirstOrDefault(x => x.Type == type); + + return found as ICredential; + } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs index dabf8de3..f2f9879b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials; -public interface ICredential +public interface ICredential { - TUserId UserId { get; } + UserKey UserKey { get; init; } CredentialType Type { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs deleted file mode 100644 index 2686afc3..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials; - -public interface ICredentialSecretStore -{ - Task SetAsync(TenantKey tenant, TUserId userId, CredentialType type, string secretHash, CancellationToken ct = default); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs index 0d1f87f3..e55c42b6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -1,17 +1,18 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials; -public interface ICredentialStore +public interface ICredentialStore { - Task>>FindByLoginAsync(TenantKey tenant, string loginIdentifier, CancellationToken ct = default); - Task>>GetByUserAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); - Task>>GetByUserAndTypeAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default); - Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); - Task UpdateSecurityStateAsync(TenantKey tenant, TUserId userId, CredentialType type, CredentialSecurityState securityState, CancellationToken ct = default); - Task UpdateMetadataAsync(TenantKey tenant, TUserId userId, CredentialType type, CredentialMetadata metadata, CancellationToken ct = default); - Task DeleteAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default); - Task DeleteByUserAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); - Task ExistsAsync(TenantKey tenant, TUserId userId, CredentialType type, CancellationToken ct = default); + Task>GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default); + Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); + Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); + Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); + Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); + Task ExistsAsync(TenantKey tenant, UserKey userKey, CredentialType type, string? secretHash, CancellationToken ct = default); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs index 9f1ccd2c..9f1ba47a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs @@ -4,5 +4,5 @@ namespace CodeBeam.UltimateAuth.Credentials; public interface ICredentialValidator { - Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default); + Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs deleted file mode 100644 index f81f5bf8..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials; - -public interface ILoginCredential : ICredential -{ - string LoginIdentifier { get; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs index e6f6a21d..10969514 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Credentials; -public interface IPublicKeyCredential : ICredential +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 index ffc805e0..314d8395 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Credentials; -public interface ISecretCredential : ICredential +public interface ISecretCredential : ICredential { string SecretHash { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs deleted file mode 100644 index ceb20f58..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecurableCredential.cs +++ /dev/null @@ -1,8 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials; - -public interface ISecurableCredential -{ - CredentialSecurityState Security { get; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs index 4071d4a7..ba61b1a8 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs @@ -14,11 +14,11 @@ public CredentialValidator(IUAuthPasswordHasher passwordHasher, IClock clock) _clock = clock; } - public Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default) + public Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (credential is ISecurableCredential securable) + if (credential is ICredentialDescriptor securable) { if (!securable.Security.IsUsable(_clock.UtcNow)) { @@ -26,7 +26,7 @@ public Task ValidateAsync(ICredential secret) + if (credential is ISecretCredential secret) { var ok = _passwordHasher.Verify(secret.SecretHash, providedSecret); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs index caf74f5f..a4d91eec 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs @@ -2,10 +2,11 @@ public sealed record UserIdentifierDto { - public required UserIdentifierType Type { get; init; } - public required string Value { get; init; } - public bool IsPrimary { get; init; } - public bool IsVerified { get; init; } + public Guid Id { get; set; } + public required UserIdentifierType Type { get; set; } + public required string Value { get; set; } + public bool IsPrimary { get; set; } + public bool IsVerified { get; set; } public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? VerifiedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileSnapshot.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileSnapshot.cs new file mode 100644 index 00000000..b6b5b4b0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/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/Requests/DeleteUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs index 73509817..0ef75570 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs @@ -4,7 +4,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record DeleteUserIdentifierRequest { - public required UserIdentifierType Type { get; init; } - public required string Value { get; init; } - public DeleteMode Mode { get; init; } = DeleteMode.Soft; + public Guid IdentifierId { get; set; } + public DeleteMode Mode { get; set; } = DeleteMode.Soft; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs index b435933a..9cd43c8a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs @@ -2,6 +2,5 @@ public sealed record SetPrimaryUserIdentifierRequest { - public UserIdentifierType Type { get; init; } - public string Value { get; init; } = default!; + public Guid IdentifierId { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs index 2dc8ef76..7638a2cd 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs @@ -2,6 +2,5 @@ public sealed record UnsetPrimaryUserIdentifierRequest { - public UserIdentifierType Type { get; init; } - public string Value { get; init; } = default!; + public Guid IdentifierId { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs index 50a9976d..60b81875 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs @@ -2,7 +2,6 @@ public sealed record UpdateUserIdentifierRequest { - public UserIdentifierType Type { get; init; } - public string OldValue { get; init; } = default!; - public string NewValue { get; init; } = default!; + public Guid Id { get; init; } + public required string NewValue { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs index 765fecdb..0e04c873 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -2,6 +2,5 @@ public sealed record VerifyUserIdentifierRequest { - public required UserIdentifierType Type { get; init; } - public required string Value { get; init; } + public Guid IdentifierId { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index d86b3f6a..01db0848 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -11,13 +11,12 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) { - services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(typeof(InMemoryUserSecurityStore<>)); - services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); - services.TryAddScoped(typeof(IUserSecurityStateWriter<>), typeof(InMemoryUserSecurityStateWriter<>)); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddSingleton, InMemoryUserIdProvider>(); // Seed never try add diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs index ab3eccf0..71ec819a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs @@ -6,6 +6,7 @@ internal sealed class InMemoryUserSecurityState : IUserSecurityState public int FailedLoginAttempts { get; init; } public DateTimeOffset? LockedUntil { get; init; } public bool RequiresReauthentication { get; init; } + public DateTimeOffset? LastFailedAt { get; init; } public bool IsLocked => LockedUntil.HasValue && LockedUntil.Value > DateTimeOffset.UtcNow; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs index 53d47848..b0331abd 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs @@ -1,18 +1,19 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.InMemory; -internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider where TUserId : notnull +internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider { - private readonly InMemoryUserSecurityStore _store; + private readonly InMemoryUserSecurityStore _store; - public InMemoryUserSecurityStateProvider(InMemoryUserSecurityStore store) + public InMemoryUserSecurityStateProvider(InMemoryUserSecurityStore store) { _store = store; } - public Task GetAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) + public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - return Task.FromResult(_store.Get(tenant, userId)); + return Task.FromResult(_store.Get(tenant, userKey)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs index 7f2fc5ed..a97f1f44 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs @@ -1,35 +1,37 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.InMemory; -internal sealed class InMemoryUserSecurityStateWriter : IUserSecurityStateWriter where TUserId : notnull +internal sealed class InMemoryUserSecurityStateWriter : IUserSecurityStateWriter { - private readonly InMemoryUserSecurityStore _store; + private readonly InMemoryUserSecurityStore _store; - public InMemoryUserSecurityStateWriter(InMemoryUserSecurityStore store) + public InMemoryUserSecurityStateWriter(InMemoryUserSecurityStore store) { _store = store; } - public Task RecordFailedLoginAsync(TenantKey tenant, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + public Task RecordFailedLoginAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { - var current = _store.Get(tenant, userId); + var current = _store.Get(tenant, userKey); var next = new InMemoryUserSecurityState { SecurityVersion = (current?.SecurityVersion ?? 0) + 1, FailedLoginAttempts = (current?.FailedLoginAttempts ?? 0) + 1, LockedUntil = current?.LockedUntil, - RequiresReauthentication = current?.RequiresReauthentication ?? false + RequiresReauthentication = current?.RequiresReauthentication ?? false, + LastFailedAt = at }; - _store.Set(tenant, userId, next); + _store.Set(tenant, userKey, next); return Task.CompletedTask; } - public Task LockUntilAsync(TenantKey tenant, TUserId userId, DateTimeOffset lockedUntil, CancellationToken ct = default) + public Task LockUntilAsync(TenantKey tenant, UserKey userKey, DateTimeOffset lockedUntil, CancellationToken ct = default) { - var current = _store.Get(tenant, userId); + var current = _store.Get(tenant, userKey); var next = new InMemoryUserSecurityState { @@ -39,13 +41,13 @@ public Task LockUntilAsync(TenantKey tenant, TUserId userId, DateTimeOffset lock RequiresReauthentication = current?.RequiresReauthentication ?? false }; - _store.Set(tenant, userId, next); + _store.Set(tenant, userKey, next); return Task.CompletedTask; } - public Task ResetFailuresAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default) + public Task ResetFailuresAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - _store.Clear(tenant, userId); + _store.Clear(tenant, userKey); return Task.CompletedTask; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index 6af8bcb0..c5312f51 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -34,7 +34,7 @@ public InMemoryUserSeedContributor( public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { await SeedUserAsync(tenant, _ids.GetAdminUserId(), "Administrator", "admin", "admin@ultimateauth.com", "1234567890", ct); - await SeedUserAsync(tenant, _ids.GetUserUserId(), "User", "user", "user@ultimateauth.com", "9876543210", ct); + await SeedUserAsync(tenant, _ids.GetUserUserId(), "Standard User", "user", "user@ultimateauth.com", "9876543210", ct); } private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string primaryUsername, @@ -64,6 +64,7 @@ await _profiles.CreateAsync(tenant, await _identifiers.CreateAsync(tenant, new UserIdentifier { + Id = Guid.NewGuid(), Tenant = tenant, UserKey = userKey, Type = UserIdentifierType.Username, @@ -76,6 +77,7 @@ await _identifiers.CreateAsync(tenant, await _identifiers.CreateAsync(tenant, new UserIdentifier { + Id = Guid.NewGuid(), Tenant = tenant, UserKey = userKey, Type = UserIdentifierType.Email, @@ -88,6 +90,7 @@ await _identifiers.CreateAsync(tenant, await _identifiers.CreateAsync(tenant, new UserIdentifier { + Id = Guid.NewGuid(), Tenant = tenant, UserKey = userKey, Type = UserIdentifierType.Phone, diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index 36733c96..b18a1272 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -1,5 +1,6 @@ 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; @@ -8,32 +9,56 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore { - private readonly Dictionary<(TenantKey Tenant, UserIdentifierType Type, string Value), UserIdentifier> _store = new(); + private readonly Dictionary _store = new(); public Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) { - return Task.FromResult(_store.TryGetValue((tenant, type, value), out var id) && !id.IsDeleted); + ct.ThrowIfCancellationRequested(); + + var exists = _store.Values.Any(x => + x.Tenant == tenant && + x.Type == type && + x.Value == value && + !x.IsDeleted); + + return Task.FromResult(exists); } public Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) { - if (!_store.TryGetValue((tenant, type, value), out var id)) + ct.ThrowIfCancellationRequested(); + + var identifier = _store.Values.FirstOrDefault(x => + x.Tenant == tenant && + x.Type == type && + x.Value == value && + !x.IsDeleted); + + return Task.FromResult(identifier); + } + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_store.TryGetValue(id, out var identifier)) return Task.FromResult(null); - if (id.IsDeleted) + if (identifier.IsDeleted) return Task.FromResult(null); - return Task.FromResult(id); + return Task.FromResult(identifier); } public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var result = _store.Values .Where(x => x.Tenant == tenant) .Where(x => x.UserKey == userKey) .Where(x => !x.IsDeleted) - .OrderByDescending(x => x.IsPrimary) - .ThenBy(x => x.CreatedAt) + .OrderBy(x => x.CreatedAt) .ToList() .AsReadOnly(); @@ -42,140 +67,153 @@ public Task> GetByUserAsync(TenantKey tenant, User public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default) { - var key = (tenant, identifier.Type, identifier.Value); + ct.ThrowIfCancellationRequested(); + + if (identifier.Id == Guid.Empty) + identifier.Id = Guid.NewGuid(); + + var duplicate = _store.Values.Any(x => + x.Tenant == tenant && + x.Type == identifier.Type && + x.Value == identifier.Value && + !x.IsDeleted); + + if (duplicate) + throw new UAuthConflictException("identifier_already_exists"); - if (_store.TryGetValue(key, out var existing) && !existing.IsDeleted) - throw new InvalidOperationException("Identifier already exists."); + identifier.Tenant = tenant; + + _store[identifier.Id] = identifier; - _store[key] = identifier; return Task.CompletedTask; } - public Task UpdateValueAsync(TenantKey tenant, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) + public Task UpdateValueAsync(Guid id, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (string.Equals(oldValue, newValue, StringComparison.Ordinal)) - throw new InvalidOperationException("identifier_value_unchanged"); - - var oldKey = (tenant, type, oldValue); - - if (!_store.TryGetValue(oldKey, out var identifier) || identifier.IsDeleted) + if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) throw new InvalidOperationException("identifier_not_found"); - var newKey = (tenant, type, newValue); + if (identifier.Value == newValue) + throw new InvalidOperationException("identifier_value_unchanged"); - if (_store.ContainsKey(newKey)) - throw new InvalidOperationException("identifier_value_already_exists"); + var duplicate = _store.Values.Any(x => + x.Id != id && + x.Tenant == identifier.Tenant && + x.Type == identifier.Type && + x.Value == newValue && + !x.IsDeleted); - _store.Remove(oldKey); + if (duplicate) + throw new InvalidOperationException("identifier_value_already_exists"); identifier.Value = newValue; identifier.IsVerified = false; identifier.VerifiedAt = null; identifier.UpdatedAt = updatedAt; - _store[newKey] = identifier; - return Task.CompletedTask; } - public Task MarkVerifiedAsync(TenantKey tenant, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default) + public Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default) { - var key = (tenant, type, value); + ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(key, out var id) || id.IsDeleted) - throw new InvalidOperationException("Identifier not found."); + if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) + throw new InvalidOperationException("identifier_not_found"); - if (id.IsVerified) + if (identifier.IsVerified) return Task.CompletedTask; - id.IsVerified = true; - id.VerifiedAt = verifiedAt; + identifier.IsVerified = true; + identifier.VerifiedAt = verifiedAt; + identifier.UpdatedAt = verifiedAt; return Task.CompletedTask; } - public Task SetPrimaryAsync(TenantKey tenant, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) + public Task SetPrimaryAsync(Guid id, CancellationToken ct = default) { - foreach (var id in _store.Values.Where(x => - x.Tenant == tenant && - x.UserKey == userKey && - x.Type == type && - !x.IsDeleted && + ct.ThrowIfCancellationRequested(); + + if (!_store.TryGetValue(id, out var target) || target.IsDeleted) + throw new InvalidOperationException("identifier_not_found"); + + foreach (var idf in _store.Values.Where(x => + x.Tenant == target.Tenant && + x.UserKey == target.UserKey && + x.Type == target.Type && x.IsPrimary)) { - id.IsPrimary = false; + idf.IsPrimary = false; } - var key = (tenant, type, value); - - if (!_store.TryGetValue(key, out var target) || target.IsDeleted) - throw new InvalidOperationException("Identifier not found."); - target.IsPrimary = true; + return Task.CompletedTask; } - public Task UnsetPrimaryAsync(TenantKey tenant, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) + public Task UnsetPrimaryAsync(Guid id, CancellationToken ct = default) { - var key = (tenant, type, value); + ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(key, out var target) || target.IsDeleted) - throw new InvalidOperationException("Identifier not found."); + if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) + throw new InvalidOperationException("identifier_not_found"); + + identifier.IsPrimary = false; + identifier.UpdatedAt = DateTimeOffset.UtcNow; - target.IsPrimary = false; return Task.CompletedTask; } - public Task DeleteAsync(TenantKey tenant, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { - var key = (tenant, type, value); + ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(key, out var id)) + if (!_store.TryGetValue(id, out var identifier)) return Task.CompletedTask; if (mode == DeleteMode.Hard) { - _store.Remove(key); + _store.Remove(id); return Task.CompletedTask; } - if (id.IsDeleted) + if (identifier.IsDeleted) return Task.CompletedTask; - id.IsDeleted = true; - id.DeletedAt = deletedAt; - id.IsPrimary = false; + identifier.IsDeleted = true; + identifier.DeletedAt = deletedAt; + identifier.IsPrimary = false; + identifier.UpdatedAt = deletedAt; return Task.CompletedTask; } public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { - var identifiers = _store.Values - .Where(x => x.Tenant == tenant) - .Where(x => x.UserKey == userKey) - .ToList(); + ct.ThrowIfCancellationRequested(); - foreach (var id in identifiers) + var identifiers = _store.Values.Where(x => x.Tenant == tenant && x.UserKey == userKey).ToList(); + + foreach (var identifier in identifiers) { if (mode == DeleteMode.Hard) { - _store.Remove((tenant, id.Type, id.Value)); + _store.Remove(identifier.Id); } else { - if (id.IsDeleted) + if (identifier.IsDeleted) continue; - id.IsDeleted = true; - id.DeletedAt = deletedAt; - id.IsPrimary = false; + identifier.IsDeleted = true; + identifier.DeletedAt = deletedAt; + identifier.IsPrimary = false; } } return Task.CompletedTask; } - } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index b0ee2678..a902bc57 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -27,8 +27,7 @@ public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationTok public Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) { - var baseQuery = _store.Values - .Where(x => x.Tenant == tenant); + var baseQuery = _store.Values.Where(x => x.Tenant == tenant); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => x.DeletedAt == null); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs index 429de15f..cbbd3fc0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs @@ -1,21 +1,22 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Users.InMemory; -internal sealed class InMemoryUserSecurityStore : IUserSecurityStateDebugView where TUserId : notnull +internal sealed class InMemoryUserSecurityStore : IUserSecurityStateDebugView { - private readonly ConcurrentDictionary<(TenantKey, TUserId), InMemoryUserSecurityState> _states = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey), InMemoryUserSecurityState> _states = new(); - public InMemoryUserSecurityState? Get(TenantKey tenant, TUserId userId) - => _states.TryGetValue((tenant, userId), out var state) ? state : null; + public InMemoryUserSecurityState? Get(TenantKey tenant, UserKey userKey) + => _states.TryGetValue((tenant, userKey), out var state) ? state : null; - public void Set(TenantKey tenant, TUserId userId, InMemoryUserSecurityState state) - => _states[(tenant, userId)] = state; + public void Set(TenantKey tenant, UserKey userKey, InMemoryUserSecurityState state) + => _states[(tenant, userKey)] = state; - public void Clear(TenantKey tenant, TUserId userId) - => _states.TryRemove((tenant, userId), out _); + public void Clear(TenantKey tenant, UserKey userKey) + => _states.TryRemove((tenant, userKey), out _); - public IUserSecurityState? GetState(TenantKey tenant, TUserId userId) - => Get(tenant, userId); + public IUserSecurityState? GetState(TenantKey tenant, UserKey userKey) + => Get(tenant, userKey); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index e4ff872d..5d06adc5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -6,6 +6,8 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public sealed record UserIdentifier { + + public Guid Id { get; set; } public TenantKey Tenant { get; set; } public UserKey UserKey { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 7818aa20..19040a2e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -2,7 +2,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; - namespace CodeBeam.UltimateAuth.Users.Reference.Extensions; public static class ServiceCollectionExtensions @@ -18,6 +17,8 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.AddScoped(); return services; } 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..918e00e7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs @@ -0,0 +1,136 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +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 IUserIdentifierStore _store; + private readonly IEnumerable _customResolvers; + private readonly UAuthLoginIdentifierOptions _options; + + public LoginIdentifierResolver( + IUserIdentifierStore store, + IEnumerable customResolvers, + IOptions options) + { + _store = store; + _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 normalized = Normalize(identifier); + + if (_options.EnableCustomResolvers && _options.CustomResolversFirst) + { + var custom = await TryCustomAsync(tenant, normalized, ct); + if (custom is not null) + return custom; + } + + var builtInType = DetectBuiltInType(normalized); + + if (!_options.AllowedBuiltIns.Contains(builtInType)) + { + if (_options.EnableCustomResolvers && !_options.CustomResolversFirst) + return await TryCustomAsync(tenant, normalized, ct); + + return null; + } + + var found = await _store.GetAsync(tenant, 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 string Normalize(string identifier) + => identifier.Trim(); + + 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/UserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs new file mode 100644 index 00000000..3044076e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.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 UserProfileSnapshotProvider : IUserProfileSnapshotProvider +{ + private readonly IUserProfileStore _store; + + public UserProfileSnapshotProvider(IUserProfileStore store) + { + _store = store; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var profile = await _store.GetAsync(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 index 1c3df9c4..e999d2d5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -7,6 +7,7 @@ public static class UserIdentifierMapper public static UserIdentifierDto ToDto(UserIdentifier record) => new() { + Id = record.Id, Type = record.Type, Value = record.Value, IsPrimary = record.IsPrimary, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 22463285..08089c61 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -1,6 +1,7 @@ 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; @@ -175,6 +176,8 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq await _accessOrchestrator.ExecuteAsync(context, command, ct); } + #region Identifiers + public async Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default) { var command = new GetUserIdentifiersCommand(async innerCt => @@ -193,9 +196,7 @@ public async Task> GetIdentifiersByUserAsync(Ac var command = new GetUserIdentifierCommand(async innerCt => { var identifier = await _identifierStore.GetAsync(context.ResourceTenant, type, value, innerCt); - return identifier is null - ? null - : UserIdentifierMapper.ToDto(identifier); + return identifier is null ? null : UserIdentifierMapper.ToDto(identifier); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -248,17 +249,22 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde { var command = new UpdateUserIdentifierCommand(async innerCt => { - if (string.Equals(request.OldValue, request.NewValue, StringComparison.Ordinal)) - throw new InvalidOperationException("identifier_value_unchanged"); + var identifier = await _identifierStore.GetByIdAsync(request.Id, innerCt); + + if (identifier is null || identifier.IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); EnsureOverrideAllowed(context); - if (request.Type == UserIdentifierType.Username && !_identifierOptions.AllowUsernameChange) + if (identifier.Type == UserIdentifierType.Username && !_identifierOptions.AllowUsernameChange) { - throw new InvalidOperationException("username_change_not_allowed"); + throw new UAuthIdentifierValidationException("username_change_not_allowed"); } - await _identifierStore.UpdateValueAsync(context.ResourceTenant, request.Type, request.OldValue, request.NewValue, _clock.UtcNow, innerCt); + if (string.Equals(identifier.Value, request.NewValue, StringComparison.Ordinal)) + throw new UAuthIdentifierValidationException("identifier_value_unchanged"); + + await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -268,18 +274,15 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar { var command = new SetPrimaryUserIdentifierCommand(async innerCt => { - var userKey = context.GetTargetUserKey(); + EnsureOverrideAllowed(context); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); - var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); + var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); - if (target is null) - throw new InvalidOperationException("identifier_not_found"); + EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); - EnsureOverrideAllowed(context); - EnsureVerificationRequirements(target.Type, target.IsVerified); - - await _identifierStore.SetPrimaryAsync(context.ResourceTenant, userKey, request.Type, request.Value, innerCt); + await _identifierStore.SetPrimaryAsync(request.IdentifierId, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -289,24 +292,27 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr { var command = new UnsetPrimaryUserIdentifierCommand(async innerCt => { - var userKey = context.GetTargetUserKey(); - EnsureOverrideAllowed(context); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); - var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); + var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); - if (target is null) - throw new InvalidOperationException("identifier_not_found"); + if (!identifier.IsPrimary) + throw new UAuthIdentifierValidationException("identifier_not_primary"); - if (!target.IsPrimary) - throw new InvalidOperationException("identifier_not_primary"); + var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); + + var otherLoginIdentifiers = identifiers + .Where(i => !i.IsDeleted && + IsLoginIdentifier(i.Type) && + i.Id != identifier.Id) + .ToList(); - var otherLoginIdentifiers = identifiers.Where(i => !i.IsDeleted && IsLoginIdentifier(i.Type) && !(i.Type == target.Type && i.Value == target.Value)).ToList(); if (otherLoginIdentifiers.Count == 0) - throw new InvalidOperationException("cannot_unset_last_primary_login_identifier"); + throw new UAuthIdentifierConflictException("cannot_unset_last_primary_login_identifier"); - await _identifierStore.UnsetPrimaryAsync(context.ResourceTenant, userKey, target.Type, target.Value, innerCt); + await _identifierStore.UnsetPrimaryAsync(request.IdentifierId, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -316,7 +322,8 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde { var command = new VerifyUserIdentifierCommand(async innerCt => { - await _identifierStore.MarkVerifiedAsync(context.ResourceTenant, request.Type, request.Value, _clock.UtcNow, innerCt); + EnsureOverrideAllowed(context); + await _identifierStore.MarkVerifiedAsync(request.IdentifierId, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -326,29 +333,40 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde { var command = new DeleteUserIdentifierCommand(async innerCt => { - var targetUserKey = context.GetTargetUserKey(); - EnsureOverrideAllowed(context); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, targetUserKey, innerCt); - var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); - - if (target is null) - throw new InvalidOperationException("identifier_not_found"); + var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); var loginIdentifiers = identifiers.Where(i => !i.IsDeleted && IsLoginIdentifier(i.Type)).ToList(); - if (IsLoginIdentifier(target.Type) && loginIdentifiers.Count == 1) - throw new InvalidOperationException("cannot_delete_last_login_identifier"); - if (target.IsPrimary) - throw new InvalidOperationException("cannot_delete_primary_identifier"); + if (identifier.IsPrimary) + throw new UAuthIdentifierValidationException("cannot_delete_primary_identifier"); + + if (_identifierOptions.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"); + } - await _identifierStore.DeleteAsync(context.ResourceTenant, request.Type, request.Value, request.Mode, _clock.UtcNow, innerCt); + if (IsLoginIdentifier(identifier.Type) && loginIdentifiers.Count == 1) + throw new UAuthIdentifierConflictException("cannot_delete_last_login_identifier"); + + await _identifierStore.DeleteAsync(request.IdentifierId, request.Mode, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); } + #endregion + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) { var command = new DeleteUserCommand(async innerCt => diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index b2bac720..7b7aaa2a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -10,20 +10,21 @@ public interface IUserIdentifierStore Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default); - Task UpdateValueAsync(TenantKey tenant, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); + Task UpdateValueAsync(Guid id, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); - Task MarkVerifiedAsync(TenantKey tenant, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default); + Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default); - Task SetPrimaryAsync(TenantKey tenant, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); + Task SetPrimaryAsync(Guid id, CancellationToken ct = default); - Task UnsetPrimaryAsync(TenantKey tenant, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); + Task UnsetPrimaryAsync(Guid id, CancellationToken ct = default); - Task DeleteAsync(TenantKey tenant, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, 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/IUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs index 571cc0f2..74fa08d0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs @@ -6,6 +6,7 @@ public interface IUserSecurityState int FailedLoginAttempts { get; } DateTimeOffset? LockedUntil { get; } bool RequiresReauthentication { get; } + DateTimeOffset? LastFailedAt { get; } bool IsLocked => LockedUntil.HasValue && LockedUntil > DateTimeOffset.UtcNow; } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs index bcb09246..6c97bc7e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users; -internal interface IUserSecurityStateDebugView +internal interface IUserSecurityStateDebugView { - IUserSecurityState? GetState(TenantKey tenant, TUserId userId); - void Clear(TenantKey tenant, TUserId userId); + IUserSecurityState? GetState(TenantKey tenant, UserKey userKey); + void Clear(TenantKey tenant, UserKey userKey); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs index b819f744..b22955f0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs @@ -1,8 +1,9 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users; -public interface IUserSecurityStateProvider +public interface IUserSecurityStateProvider { - Task GetAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs index 103adaba..20cb713b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs @@ -1,10 +1,11 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users; -public interface IUserSecurityStateWriter +public interface IUserSecurityStateWriter { - Task RecordFailedLoginAsync(TenantKey tenant, TUserId userId, DateTimeOffset at, CancellationToken ct = default); - Task ResetFailuresAsync(TenantKey tenant, TUserId userId, CancellationToken ct = default); - Task LockUntilAsync(TenantKey tenant, TUserId userId, DateTimeOffset lockedUntil, CancellationToken ct = default); + Task RecordFailedLoginAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default); + Task ResetFailuresAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task LockUntilAsync(TenantKey tenant, UserKey userKey, DateTimeOffset lockedUntil, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ICustomLoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ICustomLoginIdentifierResolver.cs new file mode 100644 index 00000000..8547287c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/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/Infrastructure/ILoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ILoginIdentifierResolver.cs new file mode 100644 index 00000000..0e06a7bb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/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/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/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs index 74e56fb3..01d86911 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs @@ -15,6 +15,7 @@ public class AuthStateSnapshotFactoryTests public async Task CreateAsync_should_return_snapshot_when_valid() { var provider = new Mock(); + var pprovider = new Mock(); provider.Setup(x => x.GetAsync(It.IsAny(), It.IsAny(), default)) .ReturnsAsync(new PrimaryUserIdentifiers @@ -22,7 +23,7 @@ public async Task CreateAsync_should_return_snapshot_when_valid() UserName = "admin" }); - var factory = new AuthStateSnapshotFactory(provider.Object); + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object); var validation = SessionValidationResult.Active( TenantKey.FromInternal("__single__"), @@ -44,7 +45,9 @@ public async Task CreateAsync_should_return_snapshot_when_valid() public async Task CreateAsync_should_return_null_when_invalid() { var provider = new Mock(); - var factory = new AuthStateSnapshotFactory(provider.Object); + var pprovider = new Mock(); + + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object); var validation = SessionValidationResult.Invalid(SessionState.NotFound); var snapshot = await factory.CreateAsync(validation); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs index 0d2182a8..f3d7dd4c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs @@ -57,12 +57,12 @@ public void MarkRefreshOutcomes_ShouldIncrementCorrectCounters() diagnostics.MarkRefreshTouched(); diagnostics.MarkRefreshNoOp(); diagnostics.MarkRefreshReauthRequired(); - diagnostics.MarkRefreshUnknown(); + diagnostics.MarkRefreshSuccess(); Assert.Equal(1, diagnostics.RefreshTouchedCount); Assert.Equal(1, diagnostics.RefreshNoOpCount); Assert.Equal(1, diagnostics.RefreshReauthRequiredCount); - Assert.Equal(1, diagnostics.RefreshUnknownCount); + Assert.Equal(1, diagnostics.RefreshSuccessCount); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs index b073c2a8..3a0cfd00 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs @@ -27,6 +27,6 @@ public void Parse_UnknownOrInvalidValues_ReturnsNone(string? input) { var result = RefreshOutcomeParser.Parse(input); - Assert.Equal(RefreshOutcome.None, result); + Assert.Equal(RefreshOutcome.Success, result); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs index cb3ee496..2fd7b759 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs @@ -30,7 +30,7 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta .Setup(x => x.ValidateAsync()) .ReturnsAsync(new AuthValidationResult { - IsValid = true, + State = SessionState.Active, Snapshot = snapshot }); @@ -57,7 +57,7 @@ public async Task EnsureAsync_force_should_always_validate() client.Setup(x => x.Flows.ValidateAsync()) .ReturnsAsync(new AuthValidationResult { - IsValid = false + State = SessionState.Invalid }); var manager = new UAuthStateManager(client.Object, clock.Object); @@ -77,7 +77,7 @@ public async Task EnsureAsync_invalid_should_clear_state() client.Setup(x => x.Flows.ValidateAsync()) .ReturnsAsync(new AuthValidationResult { - IsValid = false + State = SessionState.Invalid }); var manager = new UAuthStateManager(client.Object, clock.Object); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs index 0da6e73b..f2c6ac31 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs @@ -41,7 +41,7 @@ public void Lockout_enabled_without_duration_should_fail() var options = new UAuthLoginOptions { MaxFailedAttempts = 3, - LockoutMinutes = 0 + LockoutDuration = TimeSpan.Zero }; var validator = new UAuthLoginOptionsValidator(); @@ -57,7 +57,7 @@ public void Lockout_disabled_should_allow_zero_duration() var options = new UAuthLoginOptions { MaxFailedAttempts = 0, - LockoutMinutes = 0 + LockoutDuration = TimeSpan.Zero }; var validator = new UAuthLoginOptionsValidator(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index d5518a18..f1572c2a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -69,11 +69,11 @@ public Task RefreshAsync(bool isAuto = false) { var outcome = _outcomes.Count > 0 ? _outcomes.Dequeue() - : RefreshOutcome.None; + : RefreshOutcome.Success; return Task.FromResult(new RefreshResult { - Ok = true, + IsSuccess = true, Outcome = outcome }); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 9cbcab5c..368fa3bb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -15,6 +15,7 @@ using CodeBeam.UltimateAuth.Tokens.InMemory; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -44,8 +45,9 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddUltimateAuthInMemoryTokens(); services.AddUltimateAuthAuthorizationInMemory(); services.AddUltimateAuthAuthorizationReference(); + services.AddUltimateAuthUsersReference(); - services.AddScoped, LoginOrchestrator>(); + services.AddScoped(); services.AddScoped(); var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); @@ -57,8 +59,8 @@ public TestAuthRuntime(Action? configureServer = null, Actio Services.GetRequiredService().RunAsync(null).GetAwaiter().GetResult(); } - public ILoginOrchestrator GetLoginOrchestrator() - => Services.GetRequiredService>(); + public ILoginOrchestrator GetLoginOrchestrator() + => Services.GetRequiredService(); public ValueTask CreateLoginFlowAsync(TenantKey? tenant = null) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs index ce721ead..496c5873 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; @@ -17,8 +18,8 @@ private TestRedirectResolver(AuthRedirectResolver inner) public RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext ctx) => _inner.ResolveSuccess(flow, ctx); - public RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext ctx, AuthFailureReason reason) - => _inner.ResolveFailure(flow, ctx, reason); + 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) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index 5843e160..604465d5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -71,7 +71,7 @@ await orchestrator.LoginAsync(flow, Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService>(); + var store = runtime.Services.GetRequiredService(); var state = store.GetState(TenantKey.Single, TestUsers.User); @@ -107,7 +107,7 @@ await orchestrator.LoginAsync(flow, Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService>(); + var store = runtime.Services.GetRequiredService(); var state = store.GetState(TenantKey.Single, TestUsers.User); state.Should().BeNull(); @@ -171,7 +171,7 @@ await orchestrator.LoginAsync(flow, Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService>(); + var store = runtime.Services.GetRequiredService(); var state = store.GetState(TenantKey.Single, TestUsers.User); state!.IsLocked.Should().BeTrue(); @@ -231,7 +231,7 @@ await orchestrator.LoginAsync(flow, Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService>(); + var store = runtime.Services.GetRequiredService(); var state1 = store.GetState(TenantKey.Single, TestUsers.User); await orchestrator.LoginAsync(flow, @@ -271,7 +271,7 @@ await orchestrator.LoginAsync(flow, }); } - var store = runtime.Services.GetRequiredService>(); + var store = runtime.Services.GetRequiredService(); var state = store.GetState(TenantKey.Single, TestUsers.User); state!.IsLocked.Should().BeFalse(); @@ -303,7 +303,7 @@ public async Task Locked_user_failed_login_should_not_extend_lockout_duration() var runtime = new TestAuthRuntime(configureServer: o => { o.Login.MaxFailedAttempts = 1; - o.Login.LockoutMinutes = 15; + o.Login.LockoutDuration = TimeSpan.FromMinutes(15); }); var orchestrator = runtime.GetLoginOrchestrator(); @@ -318,7 +318,7 @@ await orchestrator.LoginAsync(flow, Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService>(); + var store = runtime.Services.GetRequiredService(); var state1 = store.GetState(TenantKey.Single, TestUsers.User); var lockedUntil = state1!.LockedUntil; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs index 45703d8a..13180acb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; @@ -8,7 +9,10 @@ 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; @@ -73,9 +77,10 @@ public void Absolute_ReturnUrl_Is_Used_When_Override_Allowed() enabled: true, successPath: "/welcome", failurePath: null, - failureQueryKey: null, failureCodes: null, - allowReturnUrlOverride: true + allowReturnUrlOverride: true, + includeLockoutTiming: true, + includeRemainingAttempts: false ) ); @@ -95,9 +100,10 @@ public void Absolute_ReturnUrl_Is_Ignored_When_Override_Disabled() enabled: true, successPath: "/welcome", failurePath: null, - failureQueryKey: null, failureCodes: null, - allowReturnUrlOverride: false + allowReturnUrlOverride: false, + includeLockoutTiming: true, + includeRemainingAttempts: false ) ); @@ -116,9 +122,10 @@ public void Relative_ReturnUrl_Is_Combined_With_BaseAddress() enabled: true, successPath: "/welcome", failurePath: null, - failureQueryKey: null, failureCodes: null, - allowReturnUrlOverride: true + allowReturnUrlOverride: true, + includeLockoutTiming: true, + includeRemainingAttempts: false ) ); @@ -138,9 +145,10 @@ public void SuccessPath_Is_Used_When_No_ReturnUrl() enabled: true, successPath: "/welcome", failurePath: null, - failureQueryKey: null, failureCodes: null, - allowReturnUrlOverride: true + allowReturnUrlOverride: true, + includeLockoutTiming: true, + includeRemainingAttempts: false ) ); @@ -159,9 +167,10 @@ public void Absolute_ReturnUrl_Outside_AllowedOrigins_Throws() enabled: true, successPath: "/welcome", failurePath: null, - failureQueryKey: null, failureCodes: null, - allowReturnUrlOverride: true + allowReturnUrlOverride: true, + includeLockoutTiming: true, + includeRemainingAttempts: false ) ); @@ -173,18 +182,19 @@ public void Absolute_ReturnUrl_Outside_AllowedOrigins_Throws() } [Fact] - public void Failure_Redirect_Contains_Mapped_Error_Code() + public void Failure_Redirect_Contains_Payload_With_Mapped_Error_Code() { var redirect = new EffectiveRedirectResponse( enabled: true, successPath: "/welcome", failurePath: "/login", - failureQueryKey: "error", failureCodes: new Dictionary { [AuthFailureReason.InvalidCredentials] = "bad_credentials" }, - allowReturnUrlOverride: false + allowReturnUrlOverride: false, + includeLockoutTiming: true, + includeRemainingAttempts: true ); var flow = AuthFlowTestFactory.LoginSuccess( @@ -194,7 +204,26 @@ public void Failure_Redirect_Contains_Mapped_Error_Code() var resolver = TestRedirectResolver.Create(); var ctx = TestHttpContext.Create(); + var decision = resolver.ResolveFailure(flow, ctx, AuthFailureReason.InvalidCredentials); - decision.TargetUrl.Should().Be("https://app.example.com/login?error=bad_credentials"); + + 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); } } From 7ed56fd7ce23a6dda5572284c4e99d10e646ee36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 14 Mar 2026 02:27:26 +0300 Subject: [PATCH 33/50] Sample Improvement (Part 2/2) (#21) * Sample Improvement (Part 2/2) * Identifier Normalization & Improved Exists Logic * Complete Identifier and Add New Tests * Complete Needed Identifier Logics & Improved PagedResult * Added Concurrency Support & Identifier Implementation * Add & Fix Test * Added Session Concurrency * Improved Session Domains & Basic Device Id Binding * Improved Device Context Properties * Added State Clear on Current Chain Revoke * Credential Enhancement * Support Credential Level Lockout * Complete Credential Change & Tests * Complete Credential Reset & Added Tests * Added Generic InMemory Store and User Lifecycle Store Implementation * Completed User Create * Enhanced Authorization (RBAC) * Authorization Completion & Tests * Fixed Identifier Concurrency Test * Profile Dialog & UAuthClientEvents * User Self Deletion * Added Self User Status Change * Complete Logout & Revoke Semantics & Added Tests * Home Page Design and ReauthRequired Raiese Event Test Fix * Admin Role Crud Dialog & State Handling Mode Enhancement :) * Permission Set Logic * UAuthStateView Enhancement * Completed User Role Management * Finalized Endpoints & Admin User Management Dialog * Finalize Blazor Server Sample --- UltimateAuth.slnx | 1 + ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 1 + .../Program.cs | 4 +- ...am.UltimateAuth.Sample.BlazorServer.csproj | 1 + .../Common/UAuthDialog.cs | 29 + .../Custom/UAuthPageComponent.razor | 10 + .../Dialogs/AccountStatusDialog.razor | 93 +++ .../Components/Dialogs/CreateUserDialog.razor | 76 ++ .../Components/Dialogs/CredentialDialog.razor | 134 ++++ .../Components/Dialogs/IdentifierDialog.razor | 307 ++++++-- .../Components/Dialogs/PermissionDialog.razor | 160 ++++ .../Components/Dialogs/ProfileDialog.razor | 199 +++++ .../Components/Dialogs/ResetDialog.razor | 75 ++ .../Components/Dialogs/RoleDialog.razor | 238 ++++++ .../Components/Dialogs/SessionDialog.razor | 492 ++++++++++++ .../Components/Dialogs/UserDetailDialog.razor | 165 ++++ .../Components/Dialogs/UserRoleDialog.razor | 154 ++++ .../Components/Dialogs/UsersDialog.razor | 251 +++++++ .../Components/Layout/MainLayout.razor | 9 +- .../Components/Layout/MainLayout.razor.cs | 54 ++ .../Components/Pages/AuthorizedTestPage.razor | 2 + .../Components/Pages/Home.razor | 397 +++++----- .../Components/Pages/Home.razor.cs | 76 +- .../Components/Pages/LandingPage.razor.cs | 2 +- .../Components/Pages/Login.razor | 14 +- .../Components/Pages/Login.razor.cs | 27 +- .../Components/Pages/NotAuthorized.razor | 4 +- .../Components/Pages/Register.razor | 54 ++ .../Components/Pages/Register.razor.cs | 45 ++ .../Components/Pages/ResetCredential.razor | 18 + .../Components/Pages/ResetCredential.razor.cs | 49 ++ .../Program.cs | 19 +- .../wwwroot/app.css | 74 +- .../Pages/Home.razor.cs | 3 +- .../IUAuthStateManager.cs | 5 + .../UAuthAuthenticationStateProvider.cs | 0 .../UAuthCascadingStateProvider.cs | 0 .../UAuthState.cs | 70 +- .../UAuthStateChangeReason.cs | 4 +- .../AuthState/UAuthStateEvent.cs | 15 + .../AuthState/UAuthStateEventArgs.cs | 16 + .../AuthState/UAuthStateEventHandlingMode.cs | 8 + .../AuthState/UAuthStateManager.cs | 145 ++++ .../Authentication/UAuthStateManager.cs | 58 -- .../Components/UALoginDispatch.razor | 2 +- .../Components/UAuthApp.razor.cs | 44 +- .../Components/UAuthFlowPageBase.cs | 2 + .../Components/UAuthLoginForm.razor | 2 +- .../Components/UAuthLoginForm.razor.cs | 2 +- .../Components/UAuthStateView.razor | 33 +- .../Components/UAuthStateView.razor.cs | 113 ++- .../Device/BrowserDeviceIdStorage.cs | 13 +- .../Events/IUAuthClientEvents.cs | 8 + .../Events/UAuthClientEvents.cs | 21 + .../Extensions/ServiceCollectionExtensions.cs | 3 + .../Login/UAuthLoginPageDiscovery.cs | 2 +- .../Infrastructure/SessionCoordinator.cs | 79 +- .../Options/UAuthClientOptions.cs | 1 + .../Options/UAuthStateEventOptions.cs | 6 + .../Abstractions/IAuthorizationClient.cs | 21 + .../Abstractions/ICredentialClient.cs | 21 + .../Services/Abstractions/IFlowClient.cs | 26 + .../Services/Abstractions/ISessionClient.cs | 21 + .../{ => Abstractions}/IUAuthClient.cs | 1 + .../{ => Abstractions}/IUserClient.cs | 11 +- .../IUserIdentifierClient.cs | 4 +- .../Services/IAuthorizationClient.cs | 18 - .../Services/ICredentialClient.cs | 23 - .../Services/IFlowClient.cs | 17 - .../Services/UAuthAuthorizationClient.cs | 95 ++- .../Services/UAuthClient.cs | 4 +- .../Services/UAuthCredentialClient.cs | 82 +- .../Services/UAuthFlowClient.cs | 67 +- .../Services/UAuthSessionClient.cs | 109 +++ .../Services/UAuthUserClient.cs | 64 +- .../Services/UAuthUserIdentifierClient.cs | 57 +- .../Abstractions/Entity/IEntitySnapshot.cs | 6 + .../Abstractions/Entity/IVersionedEntity.cs | 6 + .../Abstractions/Infrastructure/IClock.cs | 1 + .../Infrastructure/ITokenHasher.cs | 2 +- .../Issuers/INumericCodeGenerator.cs | 6 + .../Abstractions/Issuers/ISessionIssuer.cs | 2 +- .../IAuthenticationSecurityManager.cs | 13 + .../IAuthenticationSecurityStateStore.cs | 16 + .../Services/IUAuthSessionManager.cs | 31 - .../Abstractions/Stores/ISessionStore.cs | 40 + ...rnelFactory.cs => ISessionStoreFactory.cs} | 8 +- .../Stores/ISessionStoreKernel.cs | 29 - .../Abstractions/Stores/ISoftDeleteable.cs | 9 + .../Abstractions/Stores/IVersionedStore.cs | 17 + .../Stores/InMemoryVersionedStore.cs | 120 +++ .../Contracts/Access/AccessScope.cs | 9 + .../Contracts/Auth/AuthIdentitySnapshot.cs | 1 + .../Contracts/Authority/AccessContext.cs | 26 +- .../Contracts/Authority/DeviceInfo.cs | 12 +- .../Contracts/Common/CaseHandling.cs | 8 + .../Contracts/Common/PageRequest.cs | 30 + .../Contracts/Common/PagedResult.cs | 16 +- .../Contracts/Common/UAuthResult.cs | 4 + .../Contracts/Common/UAuthValidationError.cs | 5 + .../Contracts/Login/LoginRequest.cs | 5 +- .../Contracts/Revoke/RevokeResult.cs | 7 + .../Session/Dtos/SessionChainDetailDto.cs | 28 + .../Session/Dtos/SessionChainSummaryDto.cs | 21 + .../Contracts/Session/Dtos/SessionInfoDto.cs | 10 + .../Contracts/User}/UserStatus.cs | 4 +- .../Defaults/UAuthActions.cs | 132 ++++ .../{Constants => Defaults}/UAuthConstants.cs | 18 +- .../Domain/Device/DeviceContext.cs | 51 +- .../{CredentialKind.cs => GrantKind.cs} | 2 +- ...yCredentialKind.cs => PrimaryGrantKind.cs} | 2 +- .../Security/AuthenticationSecurityScope.cs | 7 + .../Security/AuthenticationSecurityState.cs | 428 +++++++++++ .../Domain/Security}/CredentialType.cs | 2 +- .../Domain/Security/ResetCodeType.cs | 7 + .../Domain/Session/AuthSessionId.cs | 41 +- .../Domain/Session/SessionChainId.cs | 24 +- .../Domain/Session/SessionChainState.cs | 8 + .../Domain/Session/SessionRootId.cs | 24 +- .../Domain/Session/UAuthSession.cs | 113 +-- .../Domain/Session/UAuthSessionChain.cs | 221 ++++-- .../Domain/Session/UAuthSessionRoot.cs | 79 +- .../Domain/Token/StoredRefreshToken.cs | 7 +- .../Domain/User/UserRuntimeRecord.cs | 1 + .../Base/UAuthAuthorizationException.cs | 9 - .../Runtime/UAuthAuthorizationException.cs | 14 + .../Runtime/UAuthConcurrencyException.cs | 14 + .../Errors/Runtime/UAuthNotFoundException.cs | 14 + .../Errors/UAuthNotFoundException.cs | 8 - .../Extensions/ClaimsSnapshotExtensions.cs | 3 +- .../Options/UAuthLoginOptions.cs | 15 +- .../Options/UAuthSessionOptions.cs | 4 - .../Abstractions/ICredentialResponseWriter.cs | 6 +- .../IPrimaryCredentialResolver.cs | 2 +- .../Auth/AuthStateSnapshotFactory.cs | 20 +- .../Auth/ClientProfileReader.cs | 2 +- .../Auth/Context/AccessContextFactory.cs | 7 +- ...AuthResponseOptionsModeTemplateResolver.cs | 24 +- .../Auth/Response/AuthResponseResolver.cs | 6 +- .../ClientProfileAuthResponseAdapter.cs | 8 +- .../UAuthAuthenticationExtension.cs | 4 +- .../UAuthAuthenticationHandler.cs | 7 +- .../UAuthAuthenticationSchemeOptions.cs | 0 .../AuthenticationSecurityManager.cs | 61 ++ .../UltimateAuthServerBuilderValidation.cs | 2 +- .../Contracts/ResolvedCredential.cs | 2 +- .../Defaults/UAuthActions.cs | 68 -- .../Defaults/UAuthSchemeDefaults.cs | 6 - .../IAuthorizationEndpointHandler.cs | 9 +- .../ICredentialEndpointHandler.cs | 18 +- .../Abstractions/ILogoutEndpointHandler.cs | 10 +- .../Abstractions/ISessionEndpointHandler.cs | 20 + .../Abstractions/ISessionManagementHandler.cs | 11 - .../Abstractions/IUserEndpointHandler.cs | 5 + .../Endpoints/LoginEndpointHandler.cs | 7 +- .../Endpoints/LogoutEndpointHandler.cs | 124 ++- .../Endpoints/PkceEndpointHandler.cs | 7 +- .../Endpoints/RefreshEndpointHandler.cs | 10 +- .../Endpoints/SessionEndpointHandler.cs | 204 +++++ .../Endpoints/UAuthEndpointRegistrar.cs | 310 +++++--- .../Endpoints/ValidateEndpointHandler.cs | 2 +- .../HttpContext/HttpContextJsonExtensions.cs | 43 ++ .../HttpContextReturnUrlExtensions.cs | 2 +- .../HttpContextSessionExtensions.cs | 4 +- .../HttpContextTenantExtensions.cs | 2 +- .../HttpContextUserExtensions.cs | 4 +- .../Extensions/HttpContextJsonExtensions.cs | 25 - .../Extensions/ServiceCollectionExtensions.cs | 29 +- .../Flows/Login/LoginAuthority.cs | 2 +- .../Flows/Login/LoginDecisionContext.cs | 5 +- .../Flows/Login/LoginOrchestrator.cs | 131 ++-- .../Flows/Refresh/IRefreshResponsePolicy.cs | 2 +- .../Flows/Refresh/RefreshResponsePolicy.cs | 14 +- .../Flows/Refresh/RefreshResponseWriter.cs | 2 +- .../Flows/Refresh/SessionTouchService.cs | 23 +- .../Cookies/IUAuthCookiePolicyBuilder.cs | 2 +- .../Cookies/UAuthCookiePolicyBuilder.cs | 12 +- .../Credentials/CredentialResponseWriter.cs | 18 +- .../Credentials/FlowCredentialResolver.cs | 8 +- .../Credentials/PrimaryCredentialResolver.cs | 2 +- .../Device/DeviceContextFactory.cs | 11 +- .../Infrastructure/Device/DeviceResolver.cs | 95 ++- .../{ => Generators}/JwtTokenGenerator.cs | 0 .../Generators/NumericCodeGenerator.cs | 15 + .../{ => Generators}/OpaqueTokenGenerator.cs | 3 +- .../Infrastructure/HmacSha256TokenHasher.cs | 6 +- .../Issuers/UAuthSessionIssuer.cs | 210 ++++-- .../Normalizer/IIdentifierNormalizer.cs | 8 + .../Normalizer/IdentifierNormalizer.cs | 132 ++++ .../Normalizer/NormalizedIdentifier.cs | 7 + .../Orchestrator/AccessCommand.cs | 25 + .../Orchestrator/CreateLoginSessionCommand.cs | 2 +- .../Orchestrator/RevokeAllSessionsCommand.cs | 23 - .../Orchestrator/UAuthAccessOrchestrator.cs | 27 +- .../Orchestrator/UAuthSessionOrchestrator.cs | 3 +- .../Session/SessionContextAccessor.cs | 4 +- .../User/HttpContextCurrentUser.cs | 2 +- .../Infrastructure/User/UAuthUserAccessor.cs | 6 +- .../Validator/IIdentifierValidator.cs | 9 + .../Validator/IUserCreateValidator.cs | 9 + .../Validator/IdentifierValidator.cs | 103 +++ .../Validator/UserCreateValidator.cs | 64 ++ .../SessionResolutionMiddleware.cs | 4 +- .../Middlewares/TenantMiddleware.cs | 2 +- .../Options/CredentialResponseOptions.cs | 4 +- ...erOptions.cs => UAuthIdentifierOptions.cs} | 4 +- .../UAuthIdentifierValidationOptions.cs | 59 ++ .../Options/UAuthLoginIdentifierOptions.cs | 27 +- .../Options/UAuthPrimaryCredentialPolicy.cs | 4 +- .../Options/UAuthResetOptions.cs | 22 + .../Options/UAuthServerEndpointOptions.cs | 15 +- .../Options/UAuthServerOptions.cs | 10 +- ...uthServerUserIdentifierOptionsValidator.cs | 2 +- .../Services/ISessionApplicationService.cs | 24 + .../Services/SessionApplicationService.cs | 271 +++++++ .../Services/UAuthFlowService.cs | 30 +- .../Services/UAuthSessionManager.cs | 50 -- .../Services/UAuthSessionQueryService.cs | 6 +- .../Services/UAuthSessionValidator.cs | 52 +- .../AssemblyVisibility.cs | 3 + ...ltimateAuth.Authentication.InMemory.csproj | 16 + ...nMemoryAuthenticationSecurityStateStore.cs | 79 ++ .../ServiceCollectionExtensions.cs | 14 + .../Domain/Permission.cs | 19 + .../Domain/RoleId.cs | 24 + .../Dtos/PermissionDto.cs | 6 - .../Dtos/RoleDto.cs | 6 - .../Dtos/RoleInfo.cs | 13 + .../Dtos/RoleQuery.cs | 9 + .../Dtos/UserRoleInfo.cs | 13 + .../Infrastructure/CompiledPermissionSet.cs | 47 ++ .../Infrastructure/PermissionExpander.cs | 29 + .../Infrastructure/PermissionNormalizer.cs | 43 ++ .../Infrastructure/UAuthPermissionCatalog.cs | 40 + .../Requests/CreateRoleRequest.cs | 7 + .../Requests/DeleteRoleRequest.cs | 8 + .../Requests/RenameRoleRequest.cs | 6 + .../Requests/SetPermissionsRequest.cs | 6 + .../Responses/DeleteRoleResult.cs | 14 + .../Responses/UserRolesResponse.cs | 5 +- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../InMemoryAuthorizationSeedContributor.cs | 22 +- .../Stores/InMemoryRoleStore.cs | 128 ++++ .../Stores/InMemoryUserRoleStore.cs | 84 ++- .../Commands/AssignUserRoleCommand.cs | 15 - .../Commands/GetUserRolesCommand.cs | 15 - .../Commands/RemoveUserRoleCommand.cs | 15 - .../Domain/Role.cs | 7 - .../Endpoints/AuthorizationEndpointHandler.cs | 130 +++- .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Infrastructure/RolePermissionResolver.cs | 41 +- .../Infrastructure/UserPermissionStore.cs | 15 +- .../Services/AuthorizationService.cs | 40 +- .../Services/RoleService.cs | 126 ++++ .../Services/UserRoleService.cs | 90 ++- .../Abstractions/IRolePermissionResolver.cs | 5 +- .../Abstractions/IRoleService.cs | 18 + .../Abstractions/IRoleStore.cs | 14 + .../Abstractions/IUserPermissionStore.cs | 3 +- .../Abstractions/IUserRoleService.cs | 9 +- .../Abstractions/IUserRoleStore.cs | 11 +- ...CodeBeam.UltimateAuth.Authorization.csproj | 1 + .../Domain/Permission.cs | 6 - .../Domain/Role.cs | 135 ++++ .../Domain/RoleKey.cs | 8 + .../Domain/UserRole.cs | 13 + .../AuthorizationClaimsProvider.cs | 15 +- .../PermissionAccessPolicy.cs | 29 - .../Domain/CredentialKey.cs | 7 + .../Dtos/CredentialDto.cs | 10 +- .../Dtos/CredentialSecurityState.cs | 90 +-- .../Extensions/CredentialTypeParser.cs | 4 +- .../Request/AddCredentialRequest.cs | 4 +- .../Request/BeginCredentialResetRequest.cs | 10 +- .../Request/ChangeCredentialRequest.cs | 7 +- .../Request/CompleteCredentialResetRequest.cs | 8 +- .../Request/CredentialActionRequest.cs | 7 + .../Request/DeleteCredentialRequest.cs | 9 + .../Request/ResetPasswordRequest.cs | 6 +- .../Request/RevokeCredentialRequest.cs | 2 + .../Request/SetInitialCredentialRequest.cs | 4 +- .../Request/ValidateCredentialsRequest.cs | 4 +- .../Responses/AddCredentialResult.cs | 15 +- .../Responses/BeginCredentialResetResult.cs | 7 + .../Responses/ChangeCredentialResult.cs | 10 +- .../Responses/CredentialProvisionResult.cs | 4 +- .../InMemoryCredentialSeedContributor.cs | 44 +- .../InMemoryCredentialStore.cs | 154 +--- .../Commands/ActivateCredentialCommand.cs | 16 - .../Commands/AddCredentialCommand.cs | 16 - .../Commands/BeginCredentialResetCommand.cs | 16 - .../Commands/ChangeCredentialCommand.cs | 16 - .../CompleteCredentialResetCommand.cs | 16 - .../Commands/DeleteCredentialCommand.cs | 16 - .../Commands/GetAllCredentialsCommand.cs | 16 - .../Commands/RevokeCredentialCommand.cs | 16 - .../Commands/SetInitialCredentialCommand.cs | 15 - .../Domain/PasswordCredential.cs | 121 ++- .../Endpoints/CredentialEndpointHandler.cs | 117 +-- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../PasswordCredentialFactory.cs | 19 - .../PasswordUserLifecycleIntegration.cs | 9 +- .../Services/CredentialManagementService.cs | 382 ++++++++++ .../ICredentialAuthenticationService.cs | 18 + .../Services/ICredentialManagementService.cs | 21 + .../Services/IUserCredentialsService.cs | 23 - .../Services/UserCredentialsService.cs | 284 ------- .../Abstractions/ICredential.cs | 4 +- .../Abstractions/ICredentialDescriptor.cs | 5 +- .../Abstractions/ICredentialStore.cs | 11 +- .../CodeBeam.UltimateAuth.Policies.csproj | 1 + .../Defaults/DefaultPolicySet.cs | 9 +- .../Fluent/ConditionalScopeBuilder.cs | 6 +- .../Fluent/IPolicyScopeBuilder.cs | 3 +- .../Fluent/PolicyScopeBuilder.cs | 6 +- .../DenyAdminSelfModificationPolicy.cs | 35 + .../Policies/MustHavePermissionPolicy.cs | 25 + .../Policies/RequireActiveUserPolicy.cs | 11 +- .../Policies/RequireAdminPolicy.cs | 25 - .../Policies/RequireAuthenticatedPolicy.cs | 7 +- .../Policies/RequireSelfOrAdminPolicy.cs | 27 - .../Policies/RequireSelfPolicy.cs | 5 +- .../Argon2PasswordHasher.cs | 3 +- .../Data/UAuthSessionDbContext.cs | 18 +- .../SessionChainProjection.cs | 19 +- .../EntityProjections/SessionProjection.cs | 2 +- .../SessionRootProjection.cs | 7 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../Mappers/SessionChainProjectionMapper.cs | 30 +- .../Mappers/SessionProjectionMapper.cs | 10 +- .../Mappers/SessionRootProjectionMapper.cs | 12 +- .../Stores/EfCoreSessionStore.cs | 612 +++++++++++++++ ...actory.cs => EfCoreSessionStoreFactory.cs} | 8 +- .../Stores/EfCoreSessionStoreKernel.cs | 281 ------- .../InMemorySessionStore.cs | 509 +++++++++++++ ...tory.cs => InMemorySessionStoreFactory.cs} | 8 +- .../InMemorySessionStoreKernel.cs | 155 ---- .../ServiceCollectionExtensions.cs | 2 +- .../Projections/RefreshTokenProjection.cs | 2 +- .../Projections/RevokedIdTokenProjection.cs | 2 +- .../UAuthTokenDbContext.cs | 6 +- .../Dtos/IdentifierExistenceQuery.cs | 13 + .../Dtos/IdentifierExistenceScope.cs | 19 + .../Dtos/SelfUserStatus.cs | 7 + .../Dtos/Snapshots/UserLifecycleSnapshot.cs | 10 + .../{ => Snapshots}/UserProfileSnapshot.cs | 0 .../Dtos/UserIdentifierDto.cs | 8 +- .../Dtos/UserQuery.cs | 10 + .../Dtos/UserSummary.cs | 21 + .../Dtos/{UserViewDto.cs => UserView.cs} | 13 +- .../Mappers/UserStatusMapper.cs | 19 + .../Requests/ChangeUserStatusAdminRequest.cs | 3 +- .../Requests/ChangeUserStatusSelfRequest.cs | 2 +- .../Requests/CreateUserRequest.cs | 11 +- .../Requests/IdentifierExistsRequest.cs | 7 + .../Requests/LogoutDeviceRequest.cs | 8 + .../LogoutOtherDevicesAdminRequest.cs | 8 + .../Requests/LogoutOtherDevicesSelfRequest.cs | 8 + .../Requests/UserIdentifierQuery.cs | 11 + .../Responses/IdentifierExistenceResult.cs | 10 + .../Responses/IdentifierExistsResponse.cs | 6 + .../Responses/IdentifierValidationResult.cs | 22 + .../Responses/UserCreateValidatorResult.cs | 22 + .../Responses/UserStatusChangeResult.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 3 - .../InMemoryUserSecurityState.cs | 12 - .../InMemoryUserSecurityStateProvider.cs | 19 - .../InMemoryUserSecurityStateWriter.cs | 53 -- .../InMemoryUserSeedContributor.cs | 98 ++- .../Stores/InMemoryUserIdentifierStore.cs | 283 +++---- .../Stores/InMemoryUserLifecycleStore.cs | 133 ++-- .../Stores/InMemoryUserProfileStore.cs | 131 ++-- .../Stores/InMemoryUserSecurityStore.cs | 22 - .../Commands/AddUserIdentifierCommand.cs | 15 - .../Commands/ChangeUserIdentifierCommand.cs | 15 - .../Commands/ChangeUserStatusCommand.cs | 15 - .../Commands/CreateUserCommand.cs | 16 - .../Commands/DeleteUserCommand.cs | 15 - .../Commands/DeleteUserIdentifierCommand.cs | 15 - .../Commands/GetMeCommand.cs | 16 - .../Commands/GetUserIdentifierCommand.cs | 16 - .../Commands/GetUserIdentifiersCommand.cs | 16 - .../Commands/GetUserProfileCommand.cs | 16 - .../SetPrimaryUserIdentifierCommand.cs | 15 - .../UnsetPrimaryUserIdentifierCommand.cs | 15 - .../Commands/UpdateUserIdentifierCommand.cs | 15 - .../Commands/UpdateUserProfileAdminCommand.cs | 15 - .../Commands/UpdateUserProfileCommand.cs | 15 - .../Commands/UserIdentifierExistsCommand.cs | 15 - .../Commands/VerifyUserIdentifierCommand.cs | 15 - .../Contracts/UserLifecycleQuery.cs | 7 +- .../Contracts/UserProfileQuery.cs | 9 +- .../Contracts/UserProfileUpdate.cs | 17 - .../Domain/UserIdentifier.cs | 165 +++- .../Domain/UserLifecycle.cs | 97 ++- .../Domain/UserLifecycleKey.cs | 8 + .../Domain/UserProfile.cs | 169 ++++- .../Domain/UserProfileKey.cs | 8 + .../Endpoints/UserEndpointHandler.cs | 119 ++- .../Extensions/ServiceCollectonExtensions.cs | 1 + .../Infrastructure/LoginIdentifierResolver.cs | 22 +- .../UserLifecycleSnaphotProvider.cs | 29 + .../UserProfileSnapshotProvider.cs | 2 +- .../Mapping/UserIdentifierMapper.cs | 4 +- .../Mapping/UserProfileMapper.cs | 22 +- .../Services/IUserApplicationService.cs | 10 +- .../Services/UserApplicationService.cs | 704 +++++++++++++----- .../Stores/IUserIdentifierStore.cs | 21 +- .../Stores/IUserLifecycleStore.cs | 17 +- .../Stores/IUserProfileStore.cs | 16 +- .../Stores/UserRuntimeStateProvider.cs | 6 +- .../IUserLifecycleSnapshotProvider.cs | 10 + .../Abstractions/IUserSecurityEvents.cs | 12 +- .../Abstractions/IUserSecurityState.cs | 12 - .../IUserSecurityStateDebugView.cs | 10 - .../IUserSecurityStateProvider.cs | 9 - .../Abstractions/IUserSecurityStateWriter.cs | 11 - .../CompiledPermissionSetTests.cs | 113 +++ .../Authorization/UAuthActionsTests.cs | 47 ++ .../Client/AuthStateSnapshotFactoryTests.cs | 6 +- .../Client/SessionCoordinatorTests.cs | 21 +- .../Client/UAuthStateManagerTests.cs | 38 +- .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 1 + .../Core/RefreshTokenValidatorTests.cs | 6 +- .../Core/UAuthSessionChainTests.cs | 77 +- .../Core/UAuthSessionTests.cs | 4 +- .../Credentials/ChangePasswordTests.cs | 133 ++++ .../Credentials/ResetPasswordTests.cs | 245 ++++++ .../Fake/FakeFlowClient.cs | 41 + .../Helpers/AuthFlowTestFactory.cs | 6 +- .../Helpers/TestAccessContext.cs | 20 + .../Helpers/TestAuthRuntime.cs | 44 +- .../Helpers/TestClock.cs | 25 + .../Helpers/TestDevice.cs | 2 +- .../Helpers/TestHttpContext.cs | 5 +- .../Policies/ActionTextTests.cs | 52 +- .../Server/LoginOrchestratorTests.cs | 93 +-- .../Server/RedirectTests.cs | 2 +- .../Server/ServerOptionsValidatorTests.cs | 10 +- .../Sessions/SessionTests.cs | 136 ++++ .../Users/IdentifierConcurrencyTests.cs | 355 +++++++++ .../UserIdentifierApplicationServiceTests.cs | 381 ++++++++++ 442 files changed, 14038 insertions(+), 4334 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Common/UAuthDialog.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/IUAuthStateManager.cs (86%) rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/UAuthAuthenticationStateProvider.cs (100%) rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/UAuthCascadingStateProvider.cs (100%) rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/UAuthState.cs (62%) rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/UAuthStateChangeReason.cs (77%) create mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/IUAuthClient.cs (89%) rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/IUserClient.cs (56%) rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/IUserIdentifierClient.cs (84%) delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs rename src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/{ISessionStoreKernelFactory.cs => ISessionStoreFactory.cs} (66%) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs rename src/{users/CodeBeam.UltimateAuth.Users.Contracts/Dtos => CodeBeam.UltimateAuth.Core/Contracts/User}/UserStatus.cs (73%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs rename src/CodeBeam.UltimateAuth.Core/{Constants => Defaults}/UAuthConstants.cs (71%) rename src/CodeBeam.UltimateAuth.Core/Domain/Principals/{CredentialKind.cs => GrantKind.cs} (78%) rename src/CodeBeam.UltimateAuth.Core/Domain/Principals/{PrimaryCredentialKind.cs => PrimaryGrantKind.cs} (70%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs rename src/{credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos => CodeBeam.UltimateAuth.Core/Domain/Security}/CredentialType.cs (85%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthorizationException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs rename src/CodeBeam.UltimateAuth.Server/Authentication/{ => AspNetCore}/UAuthAuthenticationExtension.cs (79%) rename src/CodeBeam.UltimateAuth.Server/Authentication/{ => AspNetCore}/UAuthAuthenticationHandler.cs (95%) rename src/CodeBeam.UltimateAuth.Server/Authentication/{ => AspNetCore}/UAuthAuthenticationSchemeOptions.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs rename src/CodeBeam.UltimateAuth.Server/Extensions/{ => HttpContext}/HttpContextReturnUrlExtensions.cs (92%) rename src/CodeBeam.UltimateAuth.Server/Extensions/{ => HttpContext}/HttpContextSessionExtensions.cs (83%) rename src/CodeBeam.UltimateAuth.Server/Extensions/{ => HttpContext}/HttpContextTenantExtensions.cs (92%) rename src/CodeBeam.UltimateAuth.Server/Extensions/{ => HttpContext}/HttpContextUserExtensions.cs (84%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => Generators}/JwtTokenGenerator.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => Generators}/OpaqueTokenGenerator.cs (79%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs rename src/CodeBeam.UltimateAuth.Server/Options/{UAuthUserIdentifierOptions.cs => UAuthIdentifierOptions.cs} (91%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/RoleId.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/UserRoleInfo.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/UserRole.cs rename src/authorization/CodeBeam.UltimateAuth.Authorization/{ => Infrastructure}/AuthorizationClaimsProvider.cs (55%) delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyAdminSelfModificationPolicy.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs delete mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs delete mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/{EfCoreSessionStoreKernelFactory.cs => EfCoreSessionStoreFactory.cs} (70%) delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs rename src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/{InMemorySessionStoreKernelFactory.cs => InMemorySessionStoreFactory.cs} (63%) delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/{ => Snapshots}/UserProfileSnapshot.cs (100%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserSummary.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/{UserViewDto.cs => UserView.cs} (63%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 350cadd2..22c26b0d 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -12,6 +12,7 @@ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index 13ddab2b..32b6af51 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -14,6 +14,7 @@ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 22f00b6f..f3f88149 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -1,3 +1,4 @@ +using CodeBeam.UltimateAuth.Authentication.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; @@ -8,8 +9,6 @@ using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; using CodeBeam.UltimateAuth.Security.Argon2; -using CodeBeam.UltimateAuth.Server.Authentication; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; @@ -57,6 +56,7 @@ .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() + .AddUltimateAuthInMemoryAuthenticationSecurity() .AddUltimateAuthArgon2(); builder.Services.AddUltimateAuthClient(o => 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 index 34314a51..7cc56f81 100644 --- 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 @@ -15,6 +15,7 @@ + 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/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..7873ce91 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor @@ -0,0 +1,93 @@ +@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 + + + + + +@code { + [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 = SelfUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Your account suspended successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "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?.Problem?.Detail ?? result?.Problem?.Title ?? "Delete failed.", Severity.Error); + } + } +} \ No newline at end of file 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..479a08bd --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor @@ -0,0 +1,76 @@ +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Create User + + + + + + + + + + + + + + + + Cancel + Create + + + +@code { + 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 do not match", Severity.Error); + return; + } + + var request = new CreateUserRequest + { + UserName = _username, + Email = _email, + DisplayName = _displayName, + Password = _password + }; + + var result = await UAuthClient.Users.CreateAdminAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add(result.GetErrorText ?? "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..01c01ff0 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor @@ -0,0 +1,134 @@ +@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 + + + +@code { + 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.ChangeCredentialAsync(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.GetErrorText ?? "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 index 6aba7ba5..808e7fda 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -1,49 +1,72 @@ -@using CodeBeam.UltimateAuth.Users.Contracts +@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 + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + - + - Identifiers + + Identifiers + + + - - + + + + + + + + + + - - + + @if (context.Item.IsPrimary) { - - + + } else { - - + + } - - + + - - + + @@ -54,27 +77,41 @@ - - - - - - Add - + + + + + + + + + + + + + + + + + Add + + - - + Cancel - OK @code { + 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!; @@ -82,7 +119,8 @@ [Parameter] public UAuthState AuthState { get; set; } = default!; - private List _identifiers = new(); + [Parameter] + public UserKey? UserKey { get; set; } protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -93,12 +131,86 @@ var result = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); if (result != null && result.IsSuccess && result.Value != null) { - _identifiers = result.Value.ToList(); + 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.GetMyIdentifiersAsync(req); + } + else + { + res = await UAuthClient.Identifiers.GetUserIdentifiersAsync(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(UserIdentifierDto item) { UpdateUserIdentifierRequest updateRequest = new() @@ -106,16 +218,28 @@ Id = item.Id, NewValue = item.Value }; - var result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); + } + else + { + result = await UAuthClient.Identifiers.UpdateAdminAsync(UserKey.Value, updateRequest); + } + if (result.IsSuccess) { Snackbar.Add("Identifier updated successfully", Severity.Success); } else { - Snackbar.Add("Failed to update identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to update identifier", Severity.Error); } + await ReloadAsync(); return DataGridEditFormAction.Close; } @@ -130,92 +254,153 @@ AddUserIdentifierRequest request = new() { Type = _newIdentifierType, - Value = _newIdentifierValue + Value = _newIdentifierValue, + IsPrimary = _newIdentifierPrimary }; - var result = await UAuthClient.Identifiers.AddSelfAsync(request); + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.AddSelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.AddAdminAsync(UserKey.Value, request); + } + if (result.IsSuccess) { Snackbar.Add("Identifier added successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to add identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to add identifier", Severity.Error); } } - private async Task SetPrimaryAsync(Guid id) + private async Task VerifyAsync(Guid id) { - SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; - var result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + 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() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.VerifySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.VerifyAdminAsync(UserKey.Value, request); + } + if (result.IsSuccess) { - Snackbar.Add("Primary identifier set successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + Snackbar.Add("Identifier verified successfully", Severity.Success); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to set primary identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to verify primary identifier", Severity.Error); } } - private async Task VerifyAsync(Guid id) + private async Task SetPrimaryAsync(Guid id) { - VerifyUserIdentifierRequest request = new() { IdentifierId = id }; - var result = await UAuthClient.Identifiers.VerifySelfAsync(request); + SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.SetPrimaryAdminAsync(UserKey.Value, request); + } + if (result.IsSuccess) { - Snackbar.Add("Primary identifier verified successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + Snackbar.Add("Primary identifier set successfully", Severity.Success); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to verify primary identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to set primary identifier", Severity.Error); } } private async Task UnsetPrimaryAsync(Guid id) { UnsetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; - var result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.UnsetPrimaryAdminAsync(UserKey.Value, request); + } + if (result.IsSuccess) { Snackbar.Add("Primary identifier unset successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to unset primary identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to unset primary identifier", Severity.Error); } } private async Task DeleteIdentifier(Guid id) { DeleteUserIdentifierRequest request = new() { IdentifierId = id }; - var result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.DeleteAdminAsync(UserKey.Value, request); + } + if (result.IsSuccess) { Snackbar.Add("Identifier deleted successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to delete identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to delete identifier", Severity.Error); } } - private void Submit() => MudDialog.Close(DialogResult.Ok(true)); - 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..77cf7c13 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor @@ -0,0 +1,160 @@ +@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 + + + +@code { + + [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(); + } + + void ToggleGroup(PermissionGroup group, bool value) + { + foreach (var item in group.Items) + item.Selected = value; + } + + void TogglePermission(PermissionItem item, bool value) + { + item.Selected = value; + } + + 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 SetPermissionsRequest + { + Permissions = permissions + }; + + var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + + if (!result.IsSuccess) + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "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; } + } +} \ No newline at end of file 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..7a02975b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor @@ -0,0 +1,199 @@ +@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 + + + +@code { + 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.GetProfileAsync(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.UpdateProfileAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Profile updated", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.GetErrorText ?? "Failed to update profile", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); +} \ No newline at end of file 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..1e909a9f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor @@ -0,0 +1,75 @@ +@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 + } + + + + + Cancel + OK + + + +@code { + 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 BeginCredentialResetRequest + { + 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.Problem?.Detail ?? result.Problem?.Title ?? "Failed to request credential reset.", Severity.Error); + return; + } + + _resetCode = result.Value.Token; + _resetRequested = true; + } + + private void Submit() => MudDialog.Close(DialogResult.Ok(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..0a44b8b9 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor @@ -0,0 +1,238 @@ +@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 + + + +@code { + 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.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 CommittedItemChanges(RoleInfo role) + { + var req = new RenameRoleRequest + { + Name = role.Name + }; + + var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, req); + + if (result.IsSuccess) + { + Snackbar.Add("Role renamed", Severity.Success); + } + else + { + Snackbar.Add(result.Problem?.Title ?? "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.Problem?.Title ?? "Create 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(); + var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, 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.Problem?.Detail ?? result.Problem?.Title ?? "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(); + +} \ No newline at end of file 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..c51e9987 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -0,0 +1,492 @@ +@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 + + + +@code { + private MudDataGrid? _grid; + private bool _loading = false; + private bool _reloadQueued; + private SessionChainDetailDto? _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.LogoutAllDevicesSelfAsync(); + } + else + { + result = await UAuthClient.Flows.LogoutAllDevicesAdminAsync(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.GetErrorText ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutOthersAsync() + { + var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of other devices.", Severity.Success); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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.LogoutDeviceSelfAsync(request); + } + else + { + result = await UAuthClient.Flows.LogoutDeviceAdminAsync(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.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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..92aeaa8e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor @@ -0,0 +1,165 @@ +@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 + + + +@code { + private UserView? _user; + private UserStatus _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.GetProfileAsync(UserKey); + + if (result.IsSuccess) + { + _user = result.Value; + _status = _user?.Status ?? UserStatus.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.ChangeStatusAdminAsync(_user.UserKey, request); + + if (result.IsSuccess) + { + Snackbar.Add("User status updated", Severity.Success); + _user = _user with { Status = _status }; + } + else + { + Snackbar.Add(result.GetErrorText ?? "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(); + } +} \ No newline at end of file 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..9ae32dfa --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor @@ -0,0 +1,154 @@ +@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 + + + +@code { + + [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 result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); + + if (result.IsSuccess) + { + _roles.Add(_selectedRole); + Snackbar.Add("Role assigned", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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 result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); + + if (result.IsSuccess) + { + _roles.Remove(role); + Snackbar.Add("Role removed", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + } + } + + private void Close() => MudDialog.Close(); +} \ No newline at end of file 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..39f3c5e1 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor @@ -0,0 +1,251 @@ +@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 + + + +@code { + 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.QueryUsersAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.GetErrorText ?? "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.GetErrorText ?? "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(); + } +} \ No newline at end of file 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 index 336f6537..7239bf28 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -4,11 +4,11 @@ @inject NavigationManager Nav - + - UltimateAuth - - Blazor Server Sample + UltimateAuth + + Blazor Server Sample @@ -34,6 +34,7 @@ + @if (state.Identity?.SessionState is not null && state.Identity.SessionState != SessionState.Active) 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 index 8ee2d479..47d68df7 100644 --- 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 @@ -1,5 +1,8 @@ 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; @@ -56,6 +59,57 @@ private void HandleSignInClick() 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); 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..b059ee89 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor @@ -0,0 +1,2 @@ +@page "/authorized-test" +@attribute [Authorize] \ No newline at end of file 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 index f1008c9d..0518403b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -8,20 +8,139 @@ @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 + +@if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) +{ + + + + Your account is suspended. Please active it before continue. + + + + Set Active + 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, 2)) + + @((AuthState?.Identity?.DisplayName ?? "?").Substring(0, Math.Min(2, (AuthState?.Identity?.DisplayName ?? "?").Length))) + @AuthState?.Identity?.DisplayName - @foreach (var role in AuthState.Claims.Roles) + @foreach (var role in AuthState?.Claims?.Roles ?? Enumerable.Empty()) { @role @@ -66,7 +185,7 @@ Authenticated - @(AuthState.IsAuthenticated ? "Yes" : "No") + @(AuthState?.IsAuthenticated == true ? "Yes" : "No") @@ -134,6 +253,7 @@ Last Validated At + @* TODO: Validation call should update last validated at *@ @FormatLocalTime(AuthState?.LastValidatedAt) @@ -186,202 +306,121 @@ - - - - - Session + + + + + @GetHealthText() + - - - - Validate - - + Lifecycle - - - Manual Refresh - - - - - - Logout - - - + - - - Account - - Manage Identifiers - - - - Change Password - - - - - - Admin - - - - - - - - @if (AuthState.IsInRole("Admin") || _showAdminPreview) + + + Started + @Diagnostics.StartCount + + @if (Diagnostics.StartedAt is not null) { - - - - Add User - - - - - Assign Role - - - + + + + @FormatRelative(Diagnostics.StartedAt) + + } - - @if (_showAdminPreview) + + + + + Stopped + @Diagnostics.StopCount + + + + + + Terminated + @Diagnostics.TerminatedCount + + @if (Diagnostics.TerminatedAt is not null) { - - Admin operations are shown for preview. Sign in as an Admin to execute them. - + + + + + @FormatRelative(Diagnostics.TerminatedAt) + + + } + - - - - - - @GetHealthText() - + - Lifecycle + + Refresh Metrics + - + - - - Started - @Diagnostics.StartCount + + + Total Attempts + @Diagnostics.RefreshAttemptCount - @if (Diagnostics.StartedAt is not null) - { - - - - - @FormatRelative(Diagnostics.StartedAt) - - - - } - - - Stopped - - @Diagnostics.StopCount - + + + + Success + + @Diagnostics.RefreshSuccessCount - - - Terminated - @Diagnostics.TerminatedCount + + + Automatic + @Diagnostics.AutomaticRefreshCount - @if (Diagnostics.TerminatedAt is not null) - { - - - - - @FormatRelative(Diagnostics.TerminatedAt) - - - - } - - - - Refresh Metrics - - - - - - - Total Attempts - @Diagnostics.RefreshAttemptCount - - - - - - - Success - - @Diagnostics.RefreshSuccessCount - - - - - - Automatic - @Diagnostics.AutomaticRefreshCount - - - - - - Manual - @Diagnostics.ManualRefreshCount - - + + + Manual + @Diagnostics.ManualRefreshCount + + - - - Touched - @Diagnostics.RefreshTouchedCount - - + + + Touched + @Diagnostics.RefreshTouchedCount + + - - - No-Op - @Diagnostics.RefreshNoOpCount - - + + + No-Op + @Diagnostics.RefreshNoOpCount + + - - - Reauth Required - @Diagnostics.RefreshReauthRequiredCount - - - + + + Reauth Required + @Diagnostics.RefreshReauthRequiredCount + + - - + + + - \ No newline at end of file + 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 index 38a6f1cc..72aab311 100644 --- 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 @@ -1,8 +1,11 @@ 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.Common; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; using System.Security.Claims; @@ -60,6 +63,11 @@ private async Task Validate() 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 @@ -98,10 +106,6 @@ private async Task Validate() } } - private Task CreateUser() => Task.CompletedTask; - private Task AssignRole() => Task.CompletedTask; - private Task ChangePassword() => Task.CompletedTask; - private Color GetHealthColor() { if (Diagnostics.RefreshReauthRequiredCount > 0) @@ -151,30 +155,76 @@ private string GetHealthText() 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()); + } - await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), GetDialogOptions()); + private async Task OpenSessionDialog() + { + await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), UAuthDialog.GetDialogOptions()); } - private DialogOptions GetDialogOptions() + private async Task OpenCredentialDialog() { - return new DialogOptions - { - MaxWidth = MaxWidth.Medium, - FullWidth = true, - CloseButton = true - }; + await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenAccountStatusDialog() + { + await DialogService.ShowAsync("Manage Account", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + 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 = SelfUserStatus.Active }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Account activated successfully.", Severity.Success); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Activation failed.", Severity.Error); + } + } + + private string? _roles = "Admin"; + private void RefreshHiddenState() + { + if (_roles == "Admin") + { + _roles = "User"; + return; + } + + _roles = "Admin"; + } + public override void Dispose() { base.Dispose(); 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 index 41a7e106..ab16ba5e 100644 --- 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 @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; 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 index 1db4091f..7687854b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -7,9 +7,10 @@ @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 b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor new file mode 100644 index 00000000..e32cc79c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor @@ -0,0 +1,54 @@ +@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..82645070 --- /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 succesfully.", Severity.Success); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "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..fd66181e --- /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 CompleteCredentialResetRequest + { + 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/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 19af773b..dee4c413 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,22 +1,24 @@ +using CodeBeam.UltimateAuth.Authentication.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; +using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference.Extensions; -using CodeBeam.UltimateAuth.Client; +using Microsoft.AspNetCore.HttpOverrides; using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; -using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; var builder = WebApplication.CreateBuilder(args); @@ -45,7 +47,7 @@ //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.UserIdentifiers.AllowMultipleUsernames = true; + o.Identifiers.AllowMultipleUsernames = true; }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() @@ -55,16 +57,25 @@ .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() + .AddUltimateAuthInMemoryAuthenticationSecurity() .AddUltimateAuthArgon2(); builder.Services.AddUltimateAuthClient(o => { //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; + //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; }); builder.Services.AddScoped(); +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto; +}); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) @@ -82,6 +93,8 @@ await seedRunner.RunAsync(null); } +app.UseForwardedHeaders(); + app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css index 202b506b..2b9a4745 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css @@ -50,12 +50,6 @@ h1:focus { border-color: #929292; } -.uauth-page { - height: calc(100vh - var(--mud-appbar-height)); - width: 100vw; - margin-top: 64px; -} - .uauth-stack { min-height: 60vh; max-height: calc(100vh - var(--mud-appbar-height)); @@ -68,7 +62,7 @@ h1:focus { } .uauth-login-paper { - min-height: 60vh; + min-height: 70vh; } .uauth-login-paper.mud-theme-primary { @@ -81,13 +75,69 @@ h1:focus { } .uauth-logo-slide { - transition: transform 1s cubic-bezier(.4, 0, .2, 1); -} - -.uauth-login-paper:hover .uauth-logo-slide { - transform: translateY(200px) rotateY(360deg); + 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/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs index 55315ca7..6ecdf083 100644 --- 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 @@ -41,12 +41,11 @@ private void OnDiagnosticsChanged() private async Task ProgrammaticLogin() { - var device = await DeviceIdProvider.GetOrCreateAsync(); + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { Identifier = "admin", Secret = "admin", - Device = DeviceContext.FromDeviceId(device), }; await UAuthClient.Flows.LoginAsync(request); } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs similarity index 86% rename from src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs index f30e0447..c5c28a5c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs @@ -32,4 +32,9 @@ public interface IUAuthStateManager /// 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/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticationStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticationStateProvider.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs similarity index 62% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs index 7b106dd0..cbc32fbe 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs @@ -1,6 +1,9 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +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; @@ -27,8 +30,10 @@ private UAuthState() { } public event Action? Changed; + internal Action? RequestRender; public bool IsAuthenticated => Identity is not null; + public bool NeedsValidation => IsAuthenticated && IsStale; public static UAuthState Anonymous() => new(); @@ -37,12 +42,40 @@ internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validated 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) @@ -63,6 +96,16 @@ internal void MarkStale() Changed?.Invoke(UAuthStateChangeReason.MarkedStale); } + public void Touch(bool updateState = true) + { + if (updateState) + { + IsStale = true; + } + + RequestRender?.Invoke(); + } + internal void Clear() { Identity = null; @@ -75,7 +118,28 @@ internal void Clear() public bool IsInRole(string role) => IsAuthenticated && Claims.IsInRole(role); - public bool HasPermission(string permission) => IsAuthenticated && Claims.HasPermission(permission); + 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); @@ -84,7 +148,7 @@ internal void Clear() /// /// Creates a ClaimsPrincipal view for ASP.NET / Blazor integration. /// - public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = "UltimateAuth") + public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = UAuthConstants.SchemeDefaults.GlobalScheme) { if (!IsAuthenticated || Identity is null) return new ClaimsPrincipal(new ClaimsIdentity()); diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs index 587115bf..881df3f2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs @@ -5,5 +5,7 @@ public enum UAuthStateChangeReason Authenticated, Validated, MarkedStale, - Cleared + Cleared, + Touched, + Patched } diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs new file mode 100644 index 00000000..6367d6a1 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs new file mode 100644 index 00000000..7ec39119 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs new file mode 100644 index 00000000..173f6526 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client; + +public enum UAuthStateEventHandlingMode +{ + Patch, + Validate, + None +} diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs new file mode 100644 index 00000000..26399d5d --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs deleted file mode 100644 index 0751651d..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs +++ /dev/null @@ -1,58 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; - -namespace CodeBeam.UltimateAuth.Client.Authentication; - -internal sealed class UAuthStateManager : IUAuthStateManager -{ - private readonly IUAuthClient _client; - private readonly IClock _clock; - - public UAuthState State { get; } = UAuthState.Anonymous(); - - public UAuthStateManager(IUAuthClient client, IClock clock) - { - _client = client; - _clock = clock; - } - - public async Task EnsureAsync(bool force = false, CancellationToken ct = default) - { - if (!force && State.IsAuthenticated && !State.IsStale) - return; - - var result = await _client.Flows.ValidateAsync(); - - if (!result.IsValid || result.Snapshot == null) - { - if (State.IsAuthenticated) - { - State.MarkStale(); - return; - } - - State.Clear(); - return; - } - - State.ApplySnapshot(result.Snapshot, _clock.UtcNow); - } - - public Task OnLoginAsync() - { - State.MarkStale(); - return Task.CompletedTask; - } - - public Task OnLogoutAsync() - { - State.Clear(); - return Task.CompletedTask; - } - - public void MarkStale() - { - State.MarkStale(); - } - - public bool NeedsValidation => !State.IsAuthenticated || State.IsStale; -} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor b/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor index f2b7a79d..d4942ba9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor @@ -1,7 +1,7 @@ @page "/__uauth/login-redirect" @namespace CodeBeam.UltimateAuth.Client -@using CodeBeam.UltimateAuth.Core.Constants +@using CodeBeam.UltimateAuth.Core.Defaults @using Microsoft.AspNetCore.WebUtilities @inject NavigationManager Nav diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs index 2c8c0786..cbddf34c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs @@ -23,39 +23,37 @@ protected override async Task OnInitializedAsync() protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender) - return; + if (firstRender) + { + if (_initialized) + return; - if (_initialized) - return; + _initialized = true; - _initialized = true; + StateManager.State.RequestRender = () => InvokeAsync(StateHasChanged); - await Bootstrapper.EnsureStartedAsync(); - await StateManager.EnsureAsync(); + await Bootstrapper.EnsureStartedAsync(); + await StateManager.EnsureAsync(); - if (StateManager.State.IsAuthenticated) - { - await Coordinator.StartAsync(); - _coordinatorStarted = true; - } + if (StateManager.State.IsAuthenticated) + { + await Coordinator.StartAsync(); + _coordinatorStarted = true; + } - StateManager.State.Changed += OnStateChanged; + StateManager.State.Changed += OnStateChanged; - StateHasChanged(); - } + StateHasChanged(); + } - private void OnStateChanged(UAuthStateChangeReason reason) - { - if (reason == UAuthStateChangeReason.MarkedStale) + if (StateManager.State.NeedsValidation) { - // Causes infinite loop - //_ = InvokeAsync(async () => - //{ - // await StateManager.EnsureAsync(); - //}); + await StateManager.EnsureAsync(true); } + } + private void OnStateChanged(UAuthStateChangeReason reason) + { if (reason == UAuthStateChangeReason.Authenticated) { _ = InvokeAsync(async () => diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs index 34a3ffc6..f4723276 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs @@ -13,6 +13,7 @@ public abstract class UAuthFlowPageBase : UAuthReactiveComponentBase 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; @@ -39,6 +40,7 @@ protected override void OnParametersSet() 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; diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor index 75273832..d8d4dddf 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor @@ -4,7 +4,7 @@ @using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Options @using CodeBeam.UltimateAuth.Core.Abstractions -@using CodeBeam.UltimateAuth.Core.Constants +@using CodeBeam.UltimateAuth.Core.Defaults @using CodeBeam.UltimateAuth.Core.Contracts @using CodeBeam.UltimateAuth.Core.Options @using Microsoft.Extensions.Options diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs index a820233d..cd7de6dc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor index 95894528..bbcbab52 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor @@ -2,7 +2,9 @@ @inherits UAuthReactiveComponentBase @using CodeBeam.UltimateAuth.Core.Domain +@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization +@inject IAuthorizationService AuthorizationService @if (_inactive) { @@ -15,21 +17,22 @@ @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) - } - - - @NotAuthorized - - + if (Authorized is not null) + { + @Authorized(AuthState) + } + else if (ChildContent is not null) + { + @ChildContent(AuthState) + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs index 824f7107..23dc02e9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs @@ -1,10 +1,20 @@ using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; namespace CodeBeam.UltimateAuth.Client; 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; } @@ -14,29 +24,94 @@ public partial class UAuthStateView : UAuthReactiveComponentBase [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 RequireActive { get; set; } = true; + public bool MatchAll { get; set; } = true; - private bool _inactive; + [Parameter] + public bool RequireActive { get; set; } = true; - protected override void OnParametersSet() + protected override async Task OnParametersSetAsync() { - base.OnParametersSet(); + 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 void HandleAuthStateChanged(UAuthStateChangeReason reason) + 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(roles.Any(AuthState.IsInRole)); + + if (permissions.Count > 0) + results.Add(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() @@ -62,4 +137,32 @@ private void EvaluateSessionState() _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/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs index 2c529c99..e2173571 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs @@ -15,10 +15,17 @@ public BrowserDeviceIdStorage(IBrowserStorage storage) public async ValueTask LoadAsync(CancellationToken ct = default) { - if (!await _storage.ExistsAsync(StorageScope.Local, Key)) - return null; + try + { + if (!await _storage.ExistsAsync(StorageScope.Local, Key)) + return null; - return await _storage.GetAsync(StorageScope.Local, Key); + return await _storage.GetAsync(StorageScope.Local, Key); + } + catch (TaskCanceledException) + { + return null; + } } public ValueTask SaveAsync(string deviceId, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs b/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs new file mode 100644 index 00000000..dfe52ad2 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs b/src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs new file mode 100644 index 00000000..395126c3 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 5c4e1525..9223b29b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Client.Devices; using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Client.Runtime; @@ -66,6 +67,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddSingleton(); services.AddSingleton, UAuthClientOptionsPostConfigure>(); services.TryAddSingleton(); + services.AddSingleton(); services.PostConfigure(o => { @@ -75,6 +77,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs index 171bbabd..7e1d5999 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs @@ -26,7 +26,7 @@ public static string Resolve() return _cached = "/login"; if (candidates.Count > 1) - throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Define Navigation.LoginResolver explicitly."); + 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; diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs index 96a79bec..a6de47b9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs @@ -2,30 +2,33 @@ 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.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 PeriodicTimer? _timer; private CancellationTokenSource? _cts; public event Action? ReauthRequired; - public SessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics) + 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) @@ -33,59 +36,32 @@ public async Task StartAsync(CancellationToken cancellationToken = default) if (!_options.AutoRefresh.Enabled) return; - if (_timer is not null) + if (_cts is not null) return; _diagnostics.MarkStarted(); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var interval = _options.AutoRefresh.Interval ?? TimeSpan.FromMinutes(5); - _timer = new PeriodicTimer(interval); _ = RunAsync(_cts.Token); } private async Task RunAsync(CancellationToken ct) { + var interval = _options.AutoRefresh.Interval ?? TimeSpan.FromMinutes(5); + try { - while (await _timer!.WaitForNextTickAsync(ct)) + while (!ct.IsCancellationRequested) { - _diagnostics.MarkAutomaticRefresh(); - var result = await _client.Flows.RefreshAsync(isAuto: true); - - switch (result.Outcome) - { - case RefreshOutcome.Touched: - break; - - case RefreshOutcome.NoOp: - break; - - case RefreshOutcome.Success: - break; - - case 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; - - case ReauthBehavior.None: - break; - } - _diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); - return; - } + await Task.Delay(interval, ct); + await TickAsync(); + + if (_diagnostics.IsTerminated) + return; } } catch (OperationCanceledException) { - // expected } } @@ -93,8 +69,6 @@ public Task StopAsync() { _diagnostics.MarkStopped(); _cts?.Cancel(); - _timer?.Dispose(); - _timer = null; return Task.CompletedTask; } @@ -102,4 +76,29 @@ 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/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index dab4589d..7931121d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -13,6 +13,7 @@ public sealed class UAuthClientOptions /// public string? DefaultReturnUrl { get; set; } + public UAuthStateEventOptions StateEvents { get; set; } = new(); public UAuthClientEndpointOptions Endpoints { get; set; } = new(); public UAuthClientLoginFlowOptions Login { get; set; } = new(); diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs new file mode 100644 index 00000000..51bf9632 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs new file mode 100644 index 00000000..6e45c78c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Authorization; +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(UserKey userKey, string role); + Task RemoveRoleFromUserAsync(UserKey userKey, string role); + + Task> CreateRoleAsync(CreateRoleRequest request); + Task>> QueryRolesAsync(RoleQuery request); + Task RenameRoleAsync(RoleId roleId, RenameRoleRequest request); + Task SetPermissionsAsync(RoleId roleId, SetPermissionsRequest request); + Task> DeleteRoleAsync(RoleId roleId, DeleteRoleRequest request); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs new file mode 100644 index 00000000..1bd06422 --- /dev/null +++ b/src/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(BeginCredentialResetRequest request); + Task> CompleteResetMyAsync(CompleteCredentialResetRequest request); + + Task> AddCredentialAsync(UserKey userKey, AddCredentialRequest request); + Task> ChangeCredentialAsync(UserKey userKey, ChangeCredentialRequest request); + Task RevokeCredentialAsync(UserKey userKey, RevokeCredentialRequest request); + Task> BeginResetCredentialAsync(UserKey userKey, BeginCredentialResetRequest request); + Task> CompleteResetCredentialAsync(UserKey userKey, CompleteCredentialResetRequest request); + Task DeleteCredentialAsync(UserKey userKey); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs new file mode 100644 index 00000000..fade45c2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -0,0 +1,26 @@ +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 LogoutAsync(); + Task RefreshAsync(bool isAuto = false); + //Task ReauthAsync(); + Task ValidateAsync(); + + Task BeginPkceAsync(string? returnUrl = null); + Task CompletePkceLoginAsync(PkceLoginRequest request); + + Task> LogoutDeviceSelfAsync(LogoutDeviceRequest request); + Task LogoutOtherDevicesSelfAsync(); + Task LogoutAllDevicesSelfAsync(); + Task> LogoutDeviceAdminAsync(UserKey userKey, LogoutDeviceRequest request); + Task LogoutOtherDevicesAdminAsync(UserKey userKey, LogoutOtherDevicesAdminRequest request); + Task LogoutAllDevicesAdminAsync(UserKey userKey); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs new file mode 100644 index 00000000..d4812565 --- /dev/null +++ b/src/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/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs similarity index 89% rename from src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs index 272959ef..54dcf6bd 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Client; public interface IUAuthClient { IFlowClient Flows { get; } + ISessionClient Sessions { get; } IUserClient Users { get; } IUserIdentifierClient Identifiers { get; } ICredentialClient Credentials { get; } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs similarity index 56% rename from src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index 88914fff..c4f10ce0 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -6,14 +6,17 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IUserClient { + Task>> QueryUsersAsync(UserQuery query); Task> CreateAsync(CreateUserRequest request); + Task> CreateAdminAsync(CreateUserRequest request); Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); - Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request); - Task> DeleteAsync(DeleteUserRequest request); + Task> ChangeStatusAdminAsync(UserKey userKey, ChangeUserStatusAdminRequest request); + Task DeleteMeAsync(); + Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request); - Task> GetMeAsync(); + Task> GetMeAsync(); Task UpdateMeAsync(UpdateProfileRequest request); - Task> GetProfileAsync(UserKey userKey); + Task> GetProfileAsync(UserKey userKey); Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs index 7f019423..cc2190e8 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IUserIdentifierClient { - Task>> GetMyIdentifiersAsync(); + Task>> GetMyIdentifiersAsync(PageRequest? request = null); Task AddSelfAsync(AddUserIdentifierRequest request); Task UpdateSelfAsync(UpdateUserIdentifierRequest request); Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); @@ -14,7 +14,7 @@ public interface IUserIdentifierClient Task VerifySelfAsync(VerifyUserIdentifierRequest request); Task DeleteSelfAsync(DeleteUserIdentifierRequest request); - Task>> GetUserIdentifiersAsync(UserKey userKey); + Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null); Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs deleted file mode 100644 index 192ef5c0..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs +++ /dev/null @@ -1,18 +0,0 @@ -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(); - - Task> GetUserRolesAsync(UserKey userKey); - - Task AssignRoleAsync(UserKey userKey, string role); - - Task RemoveRoleAsync(UserKey userKey, string role); -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs deleted file mode 100644 index eb92db92..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Client.Services; - -public interface ICredentialClient -{ - Task> GetMyAsync(); - Task> AddMyAsync(AddCredentialRequest request); - Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request); - Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request); - Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request); - Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request); - - Task> GetUserAsync(UserKey userKey); - Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); - Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request); - Task ActivateUserAsync(UserKey userKey, CredentialType type); - Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request); - Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request); - Task DeleteUserAsync(UserKey userKey, CredentialType type); -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs deleted file mode 100644 index bec146de..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Core.Contracts; - -// TODO: Add ReauthAsync -namespace CodeBeam.UltimateAuth.Client.Services; - -public interface IFlowClient -{ - Task LoginAsync(LoginRequest request, string? returnUrl = null); - Task LogoutAsync(); - Task RefreshAsync(bool isAuto = false); - //Task ReauthAsync(); - Task ValidateAsync(); - - Task BeginPkceAsync(string? returnUrl = null); - Task CompletePkceLoginAsync(PkceLoginRequest request); -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 4cfc6689..683db2bc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization; +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; @@ -10,11 +12,13 @@ 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, IOptions options) + public UAuthAuthorizationClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) { _request = request; + _events = events; _options = options.Value; } @@ -26,35 +30,102 @@ public async Task> CheckAsync(AuthorizationChec return UAuthResultMapper.FromJson(raw); } - public async Task> GetMyRolesAsync() + public async Task> GetMyRolesAsync(PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url("/authorization/users/me/roles/get")); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/me/authorization/roles/get"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> GetUserRolesAsync(UserKey userKey) + public async Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url($"/admin/authorization/users/{userKey}/roles/get")); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/get"), request); return UAuthResultMapper.FromJson(raw); } - public async Task AssignRoleAsync(UserKey userKey, string role) + public async Task AssignRoleToUserAsync(UserKey userKey, string role) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/post"), new AssignRoleRequest + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/assign"), new AssignRoleRequest { Role = role }); - return UAuthResultMapper.From(raw); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; } - public async Task RemoveRoleAsync(UserKey userKey, string role) + public async Task RemoveRoleFromUserAsync(UserKey userKey, string role) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/delete"), new AssignRoleRequest + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/remove"), new AssignRoleRequest { Role = role }); - return UAuthResultMapper.From(raw); + 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(RoleId roleId, RenameRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/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 SetPermissionsAsync(RoleId roleId, SetPermissionsRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/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(RoleId roleId, DeleteRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/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/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index 40d44f2a..6df7e74d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -5,14 +5,16 @@ 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, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) + public UAuthClient(IFlowClient flows, ISessionClient session, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) { Flows = flows; + Sessions = session; Users = users; Identifiers = identifiers; Credentials = credentials; diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 1ef35801..ca73037a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +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; @@ -10,93 +11,94 @@ 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, IOptions 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> GetMyAsync() - { - var raw = await _request.SendFormAsync(Url("/credentials/get")); - return UAuthResultMapper.FromJson(raw); - } - public async Task> AddMyAsync(AddCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url("/credentials/add"), request); + var raw = await _request.SendJsonAsync(Url("/me/credentials/add"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request) + public async Task> ChangeMyAsync(ChangeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/change"), 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(CredentialType type, RevokeCredentialRequest request) + public async Task RevokeMyAsync(RevokeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/revoke"), 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(CredentialType type, BeginCredentialResetRequest request) + public async Task> BeginResetMyAsync(BeginCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/begin"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/me/credentials/reset/begin"), request); + return UAuthResultMapper.FromJson(raw); } - public async Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request) + public async Task> CompleteResetMyAsync(CompleteCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/complete"), request); - return UAuthResultMapper.From(raw); + 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> GetUserAsync(UserKey userKey) - { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/get")); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) + public async Task> AddCredentialAsync(UserKey userKey, AddCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/add"), request); return UAuthResultMapper.FromJson(raw); } - public async Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request) + public async Task> ChangeCredentialAsync(UserKey userKey, ChangeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/revoke"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/change"), request); + return UAuthResultMapper.FromJson(raw); } - public async Task ActivateUserAsync(UserKey userKey, CredentialType type) + public async Task RevokeCredentialAsync(UserKey userKey, RevokeCredentialRequest request) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/activate")); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/revoke"), request); return UAuthResultMapper.From(raw); } - public async Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request) + public async Task> BeginResetCredentialAsync(UserKey userKey, BeginCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/begin"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/begin"), request); + return UAuthResultMapper.FromJson(raw); } - public async Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request) + public async Task> CompleteResetCredentialAsync(UserKey userKey, CompleteCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/complete"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/complete"), request); + return UAuthResultMapper.FromJson(raw); } - public async Task DeleteUserAsync(UserKey userKey, CredentialType type) + public async Task DeleteCredentialAsync(UserKey userKey) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/delete")); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/delete")); return UAuthResultMapper.From(raw); } - } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 3c81d96e..4a04c194 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -1,12 +1,14 @@ 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.AspNetCore.Components; using Microsoft.Extensions.Options; using System.Net; @@ -19,13 +21,15 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal class UAuthFlowClient : IFlowClient { private readonly IUAuthRequestClient _post; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; private readonly NavigationManager _nav; - public UAuthFlowClient(IUAuthRequestClient post, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) + public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) { _post = post; + _events = events; _options = options.Value; _diagnostics = diagnostics; _nav = nav; @@ -149,7 +153,11 @@ public async Task ValidateAsync() 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}"); @@ -227,6 +235,60 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) await _post.NavigateAsync(url, payload); } + public async Task> LogoutDeviceSelfAsync(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> LogoutDeviceAdminAsync(UserKey userKey, LogoutDeviceRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/{userKey.Value}/logout-device"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task LogoutOtherDevicesSelfAsync() + { + var raw = await _post.SendJsonAsync(Url("/me/logout-others")); + return UAuthResultMapper.From(raw); + } + + public async Task LogoutOtherDevicesAdminAsync(UserKey userKey, LogoutOtherDevicesAdminRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/{userKey.Value}/logout-others"), request); + return UAuthResultMapper.From(raw); + } + + public async Task LogoutAllDevicesSelfAsync() + { + 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 LogoutAllDevicesAdminAsync(UserKey userKey) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/{userKey.Value}/logout-all")); + return UAuthResultMapper.From(raw); + } + + private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) { var hubLoginUrl = Url(_options.Endpoints.HubLoginPath); @@ -242,9 +304,6 @@ private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifi return _post.NavigateAsync(hubLoginUrl, data); } - - // ---------------- PKCE CRYPTO ---------------- - private static string CreateVerifier() { var bytes = RandomNumberGenerator.GetBytes(32); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs new file mode 100644 index 00000000..b9740e7c --- /dev/null +++ b/src/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}")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> RevokeMyChainAsync(SessionChainId chainId) + { + var raw = await _request.SendJsonAsync(Url($"/me/sessions/chains/{chainId}/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}/sessions/chains"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains/{chainId}")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/{sessionId}/revoke")); + return UAuthResultMapper.From(raw); + } + + public async Task> RevokeUserChainAsync(UserKey userKey, SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains/{chainId}/revoke")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserRootAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/revoke-root")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeAllUserChainsAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/revoke-all")); + return UAuthResultMapper.From(raw); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 3f2c426b..38b7e610 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +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; @@ -10,61 +11,94 @@ 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, IOptions 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() + public async Task> GetMeAsync() { - var raw = await _request.SendFormAsync(Url("/users/me/get")); - return UAuthResultMapper.FromJson(raw); + var raw = await _request.SendFormAsync(Url("/me/get")); + return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/update"), 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>> QueryUsersAsync(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> CreateAdminAsync(CreateUserRequest request) + { + var raw = await _request.SendJsonAsync(Url("/admin/users/create"), request); + return UAuthResultMapper.FromJson(raw); + } + public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/status"), 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> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request) + public async Task> ChangeStatusAdminAsync(UserKey userKey, ChangeUserStatusAdminRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{request.UserKey.Value}/status"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/status"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> DeleteAsync(DeleteUserRequest request) + public async Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/delete")); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/delete"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> GetProfileAsync(UserKey userKey) + public async Task> GetProfileAsync(UserKey userKey) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/profile/get")); - return UAuthResultMapper.FromJson(raw); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/profile/get")); + return UAuthResultMapper.FromJson(raw); } public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/profile/update"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/update"), request); return UAuthResultMapper.From(raw); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index 7a814c6c..9f047dc3 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +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; @@ -10,62 +11,90 @@ 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, IOptions 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>> GetMyIdentifiersAsync() + public async Task>> GetMyIdentifiersAsync(PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url("/users/me/identifiers/get")); - return UAuthResultMapper.FromJson>(raw); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/get"), request); + return UAuthResultMapper.FromJson>(raw); } public async Task AddSelfAsync(AddUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/add"), 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 UpdateSelfAsync(UpdateUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/update"), 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 SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/set-primary"), 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 UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/unset-primary"), 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 VerifySelfAsync(VerifyUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/verify"), 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 DeleteSelfAsync(DeleteUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/delete"), 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>> GetUserIdentifiersAsync(UserKey userKey) + public async Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/identifiers/get")); - return UAuthResultMapper.FromJson>(raw); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/get"), request); + return UAuthResultMapper.FromJson>(raw); } public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) 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/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/Infrastructure/IClock.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs index 71e7a186..0d40c708 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs @@ -4,6 +4,7 @@ /// 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/ITokenHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs index 8112e451..1096f980 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs @@ -7,5 +7,5 @@ public interface ITokenHasher { string Hash(string plaintext); - bool Verify(string plaintext, string hash); + bool Verify(string hash, string plaintext); } 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/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index f78e4806..b427fb04 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface ISessionIssuer { - Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + Task IssueSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs new file mode 100644 index 00000000..1e12f065 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/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/Security/IAuthenticationSecurityStateStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs new file mode 100644 index 00000000..fef7743c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthenticationSecurityStateStore +{ + Task GetAsync(TenantKey tenant, 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(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs deleted file mode 100644 index 996b5a64..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions; - -/// -/// Application-level session command API. -/// Represents explicit intent to mutate session state. -/// All operations are authorization- and policy-aware. -/// -public interface IUAuthSessionManager -{ - /// - /// Revokes a single session (logout current device). - /// - Task RevokeSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); - - /// - /// Revokes all sessions in a specific chain (logout a device). - /// - Task RevokeChainAsync(SessionChainId chainId, CancellationToken ct = default); - - /// - /// Revokes all session chains for the current user (logout all devices). - /// - Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId = null, CancellationToken ct = default); - - /// - /// Hard revoke: revokes the entire session root (admin / security action). - /// - Task RevokeRootAsync(UserKey userKey, CancellationToken ct = default); -} 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..53fd7b31 --- /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(TenantKey tenant, UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); + Task RevokeAllChainsAsync(TenantKey tenant, 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(TenantKey tenant, 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/ISessionStoreKernelFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs similarity index 66% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs index 936731e2..72cd6b3e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs @@ -5,15 +5,15 @@ 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. +/// Implementations typically resolve concrete types from the dependency injection container. /// -public interface ISessionStoreKernelFactory +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. + /// An implementation able to perform session persistence operations. /// - ISessionStoreKernel Create(TenantKey tenant); + ISessionStore Create(TenantKey tenant); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs deleted file mode 100644 index 09e130c8..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions; - -public interface ISessionStoreKernel -{ - Task ExecuteAsync(Func action, CancellationToken ct = default); - Task ExecuteAsync(Func> action, CancellationToken ct = default); - - Task GetSessionAsync(AuthSessionId sessionId); - Task SaveSessionAsync(UAuthSession session); - Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); - - Task GetChainAsync(SessionChainId chainId); - Task SaveChainAsync(UAuthSessionChain chain); - Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at); - Task GetActiveSessionIdAsync(SessionChainId chainId); - Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId); - - Task GetSessionRootByUserAsync(UserKey userKey); - Task GetSessionRootByIdAsync(SessionRootId rootId); - Task SaveSessionRootAsync(UAuthSessionRoot root); - Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at); - - Task GetChainIdBySessionAsync(AuthSessionId sessionId); - Task> GetChainsByUserAsync(UserKey userKey); - Task> GetSessionsByChainAsync(SessionChainId chainId); - Task DeleteExpiredSessionsAsync(DateTimeOffset at); -} 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/Stores/InMemoryVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs new file mode 100644 index 00000000..db4d54f6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs @@ -0,0 +1,120 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +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/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs new file mode 100644 index 00000000..0a881359 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum ActionScope +{ + Anonymous, + Self, + Admin, + System +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs index 1c07c5ad..4f3812bf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs @@ -14,4 +14,5 @@ public sealed record AuthIdentitySnapshot public DateTimeOffset? AuthenticatedAt { get; init; } public SessionState? SessionState { get; init; } public string? TimeZone { get; init; } + public UserStatus UserStatus { get; set; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index 320faef7..e526683e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -11,6 +11,7 @@ public sealed class AccessContext 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; } @@ -38,7 +39,8 @@ internal AccessContext( TenantKey actorTenant, bool isAuthenticated, bool isSystemActor, - string resource, + SessionChainId? actorChainId, + string? resource, UserKey? targetUserKey, TenantKey resourceTenant, string action, @@ -48,6 +50,7 @@ internal AccessContext( ActorTenant = actorTenant; IsAuthenticated = isAuthenticated; IsSystemActor = isSystemActor; + ActorChainId = actorChainId; Resource = resource; TargetUserKey = targetUserKey; @@ -56,6 +59,27 @@ internal AccessContext( 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 diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs index 38760414..10c097d5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs @@ -6,11 +6,11 @@ public sealed class DeviceInfo { public required DeviceId DeviceId { get; init; } + // TODO: Implement device type and device limits /// - /// High-level platform classification (web, mobile, desktop, iot). - /// Used for analytics and policy decisions. + /// 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? Platform { get; init; } + public string? DeviceType { get; init; } /// /// Operating system information (e.g. iOS 17, Android 14, Windows 11). @@ -22,6 +22,12 @@ public sealed class DeviceInfo /// 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). /// 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..08e57fe5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum CaseHandling +{ + Preserve, + ToLower, + ToUpper +} 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..b236c0e5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs @@ -0,0 +1,30 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public class 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 index e3b92ad3..854eafa4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -2,12 +2,22 @@ public sealed class PagedResult { - public IReadOnlyList Items { get; } - public int TotalCount { get; } + 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 PagedResult(IReadOnlyList items, int totalCount) + 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/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index be87ad8b..e1bf2aaa 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -4,11 +4,15 @@ 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; init; } public HttpStatusInfo Http => new(Status); + public string? GetErrorText => Problem?.Detail ?? Problem?.Title; + public sealed class HttpStatusInfo { private readonly int _status; 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/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index 23769bec..c85b9b92 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -8,8 +8,8 @@ public sealed record LoginRequest public TenantKey Tenant { get; init; } public string Identifier { get; init; } = default!; public string Secret { get; init; } = default!; + public CredentialType Factor { get; init; } = CredentialType.Password; public DateTimeOffset? At { get; init; } - public required DeviceContext Device { get; init; } public IReadOnlyDictionary? Metadata { get; init; } /// @@ -17,7 +17,4 @@ public sealed record LoginRequest /// Server policy may still ignore this. /// public bool RequestTokens { get; init; } = true; - - // Optional - public SessionChainId? ChainId { 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/Dtos/SessionChainDetailDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs new file mode 100644 index 00000000..edea1d4b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class SessionChainDetailDto +{ + 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/SessionChainSummaryDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs new file mode 100644 index 00000000..51debfc8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionChainSummaryDto +{ + 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/SessionInfoDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs new file mode 100644 index 00000000..cbf966e1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionInfoDto( + AuthSessionId SessionId, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + bool IsRevoked + ); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs similarity index 73% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs index 9aff5b20..f822f02c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +namespace CodeBeam.UltimateAuth.Core.Contracts; public enum UserStatus { @@ -16,5 +16,5 @@ public enum UserStatus PendingActivation = 60, PendingVerification = 70, - Deactivated = 80, + Unknown = 99 } diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs new file mode 100644 index 00000000..faf1dc22 --- /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 ReadSelf = "authorization.roles.read.self"; + public const string ReadAdmin = "authorization.roles.read.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/Constants/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs similarity index 71% rename from src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs rename to src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs index e7bb1179..0a9418b5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs @@ -1,7 +1,23 @@ -namespace CodeBeam.UltimateAuth.Core.Constants; +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"; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs index fcc44dde..23372748 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -3,21 +3,64 @@ 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) + 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(null); + public static DeviceContext Anonymous() + => new( + deviceId: null, + deviceType: null, + platform: null, + operatingSystem: null, + browser: null, + ipAddress: null); - public static DeviceContext FromDeviceId(DeviceId deviceId) => new(deviceId); + public static DeviceContext Create( + DeviceId deviceId, + string? deviceType, + string? platform, + string? operatingSystem, + string? browser, + string? ipAddress) + { + 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. - // IP, Geo, Fingerprint, Platform, UA will be added here. + // Geo and Fingerprint will be added here. } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs similarity index 78% rename from src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs index 38e076be..601b18f8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Domain; -public enum CredentialKind +public enum GrantKind { Session, AccessToken, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs similarity index 70% rename from src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs index e5ddc547..9e12f09d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Domain; -public enum PrimaryCredentialKind +public enum PrimaryGrantKind { Stateful, Stateless 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..501db9ae --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs @@ -0,0 +1,428 @@ +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 +{ + 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; + + 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 nextCount = FailedAttempts + 1; + + DateTimeOffset? nextLockedUntil = LockedUntil; + + 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); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs similarity index 85% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs index 9b87807a..35226f1b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Core.Domain; public enum CredentialType { 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 index b978f3d4..4bc6988f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -5,7 +5,7 @@ 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 +public readonly record struct AuthSessionId : IParsable { public string Value { get; } @@ -16,19 +16,44 @@ private AuthSessionId(string value) public static bool TryCreate(string? raw, out AuthSessionId id) { - if (string.IsNullOrWhiteSpace(raw)) + if (IsValid(raw)) { - id = default; - return false; + id = new AuthSessionId(raw!); + return true; } - if (raw.Length < 32) + 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)) { - id = default; - return false; + result = new AuthSessionId(s!); + return true; } - id = new AuthSessionId(raw); + result = default; + return false; + } + + private static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (value.Length < 32) + return false; + return true; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs index c733e991..cd104b31 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; [JsonConverter(typeof(SessionChainIdJsonConverter))] -public readonly record struct SessionChainId(Guid Value) +public readonly record struct SessionChainId(Guid Value) : IParsable { public static SessionChainId New() => new(Guid.NewGuid()); @@ -32,5 +32,27 @@ public static bool TryCreate(string raw, out SessionChainId id) 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/SessionRootId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs index 60a85cfb..20e0b713 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; [JsonConverter(typeof(SessionRootIdJsonConverter))] -public readonly record struct SessionRootId(Guid Value) +public readonly record struct SessionRootId(Guid Value) : IParsable { public static SessionRootId New() => new(Guid.NewGuid()); @@ -25,5 +25,27 @@ public static bool TryCreate(string raw, out SessionRootId id) 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/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index bcb8b2a2..806a9c5b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,8 +1,10 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSession +// TODO: Add ISoftDeleteable +public sealed class UAuthSession : IVersionedEntity { public AuthSessionId SessionId { get; } public TenantKey Tenant { get; } @@ -10,28 +12,26 @@ public sealed class UAuthSession public SessionChainId ChainId { get; } public DateTimeOffset CreatedAt { get; } public DateTimeOffset ExpiresAt { get; } - public DateTimeOffset? LastSeenAt { get; } public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersionAtCreation { get; } - public DeviceContext Device { get; } public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } + public long Version { get; set; } private UAuthSession( - AuthSessionId sessionId, - TenantKey tenant, - UserKey userKey, - SessionChainId chainId, - DateTimeOffset createdAt, - DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, - bool isRevoked, - DateTimeOffset? revokedAt, - long securityVersionAtCreation, - DeviceContext device, - ClaimsSnapshot claims, - SessionMetadata metadata) + AuthSessionId sessionId, + TenantKey tenant, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + ClaimsSnapshot claims, + SessionMetadata metadata, + long version) { SessionId = sessionId; Tenant = tenant; @@ -39,13 +39,12 @@ private UAuthSession( ChainId = chainId; CreatedAt = createdAt; ExpiresAt = expiresAt; - LastSeenAt = lastSeenAt; IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; - Device = device; Claims = claims; Metadata = metadata; + Version = version; } public static UAuthSession Create( @@ -55,7 +54,7 @@ public static UAuthSession Create( SessionChainId chainId, DateTimeOffset now, DateTimeOffset expiresAt, - DeviceContext device, + long securityVersion, ClaimsSnapshot? claims, SessionMetadata metadata) { @@ -66,54 +65,12 @@ public static UAuthSession Create( chainId, createdAt: now, expiresAt: expiresAt, - lastSeenAt: now, isRevoked: false, revokedAt: null, - securityVersionAtCreation: 0, - device: device, + securityVersionAtCreation: securityVersion, claims: claims ?? ClaimsSnapshot.Empty, - metadata: metadata - ); - } - - public UAuthSession WithSecurityVersion(long version) - { - if (SecurityVersionAtCreation == version) - return this; - - return new UAuthSession( - SessionId, - Tenant, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - LastSeenAt, - IsRevoked, - RevokedAt, - version, - Device, - Claims, - Metadata - ); - } - - public UAuthSession Touch(DateTimeOffset at) - { - return new UAuthSession( - SessionId, - Tenant, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - at, - IsRevoked, - RevokedAt, - SecurityVersionAtCreation, - Device, - Claims, - Metadata + metadata: metadata, + version: 0 ); } @@ -128,13 +85,12 @@ public UAuthSession Revoke(DateTimeOffset at) ChainId, CreatedAt, ExpiresAt, - LastSeenAt, true, at, SecurityVersionAtCreation, - Device, Claims, - Metadata + Metadata, + Version + 1 ); } @@ -145,13 +101,12 @@ internal static UAuthSession FromProjection( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, - DeviceContext device, ClaimsSnapshot claims, - SessionMetadata metadata) + SessionMetadata metadata, + long version) { return new UAuthSession( sessionId, @@ -160,17 +115,16 @@ internal static UAuthSession FromProjection( chainId, createdAt, expiresAt, - lastSeenAt, isRevoked, revokedAt, securityVersionAtCreation, - device, claims, - metadata + metadata, + version ); } - public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) + public SessionState GetState(DateTimeOffset at) { if (IsRevoked) return SessionState.Revoked; @@ -178,9 +132,6 @@ public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) if (at >= ExpiresAt) return SessionState.Expired; - if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) - return SessionState.Expired; - return SessionState.Active; } @@ -196,14 +147,12 @@ public UAuthSession WithChain(SessionChainId chainId) chainId: chainId, createdAt: CreatedAt, expiresAt: ExpiresAt, - lastSeenAt: LastSeenAt, isRevoked: IsRevoked, revokedAt: RevokedAt, securityVersionAtCreation: SecurityVersionAtCreation, - device: Device, claims: Claims, - metadata: Metadata + 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 index dfecbcb3..7d9139cc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,42 +1,65 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSessionChain +public sealed class UAuthSessionChain : IVersionedEntity { public SessionChainId ChainId { get; } public SessionRootId RootId { get; } public TenantKey Tenant { get; } public UserKey UserKey { get; } - public int RotationCount { get; } - public long SecurityVersionAtCreation { 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 bool IsRevoked { 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, - int rotationCount, - long securityVersionAtCreation, + DateTimeOffset createdAt, + DateTimeOffset lastSeenAt, + DateTimeOffset? absoluteExpiresAt, + DeviceContext device, ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, - bool isRevoked, - DateTimeOffset? revokedAt) + int rotationCount, + int touchCount, + long securityVersionAtCreation, + DateTimeOffset? revokedAt, + long version) { ChainId = chainId; RootId = rootId; Tenant = tenant; UserKey = userKey; - RotationCount = rotationCount; - SecurityVersionAtCreation = securityVersionAtCreation; + CreatedAt = createdAt; + LastSeenAt = lastSeenAt; + AbsoluteExpiresAt = absoluteExpiresAt; + Device = device; ClaimsSnapshot = claimsSnapshot; ActiveSessionId = activeSessionId; - IsRevoked = isRevoked; + RotationCount = rotationCount; + TouchCount = touchCount; + SecurityVersionAtCreation = securityVersionAtCreation; RevokedAt = revokedAt; + Version = version; } public static UAuthSessionChain Create( @@ -44,26 +67,37 @@ public static UAuthSessionChain Create( SessionRootId rootId, TenantKey tenant, UserKey userKey, - long securityVersion, - ClaimsSnapshot claimsSnapshot) + 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, - claimsSnapshot: claimsSnapshot, - activeSessionId: null, - isRevoked: false, - revokedAt: null + revokedAt: null, + version: 0 ); } - public UAuthSessionChain AttachSession(AuthSessionId sessionId) + public UAuthSessionChain AttachSession(AuthSessionId sessionId, DateTimeOffset now) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) + return this; + + if (ActiveSessionId.HasValue && ActiveSessionId.Value.Equals(sessionId)) return this; return new UAuthSessionChain( @@ -71,18 +105,50 @@ public UAuthSessionChain AttachSession(AuthSessionId sessionId) 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: sessionId, - isRevoked: false, - revokedAt: null + activeSessionId: null, + RotationCount, // Unchanged on first attach + TouchCount, + SecurityVersionAtCreation, + RevokedAt, + Version + 1 ); } - public UAuthSessionChain RotateSession(AuthSessionId sessionId) + public UAuthSessionChain RotateSession(AuthSessionId sessionId, DateTimeOffset now, ClaimsSnapshot? claimsSnapshot = null) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) + return this; + + if (ActiveSessionId.HasValue && ActiveSessionId.Value.Equals(sessionId)) return this; return new UAuthSessionChain( @@ -90,18 +156,23 @@ public UAuthSessionChain RotateSession(AuthSessionId sessionId) RootId, Tenant, UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + claimsSnapshot ?? ClaimsSnapshot, + activeSessionId: sessionId, RotationCount + 1, + TouchCount, SecurityVersionAtCreation, - ClaimsSnapshot, - activeSessionId: sessionId, - isRevoked: false, - revokedAt: null + RevokedAt, + Version + 1 ); } - public UAuthSessionChain Revoke(DateTimeOffset at) + public UAuthSessionChain Touch(DateTimeOffset now, ClaimsSnapshot? claimsSnapshot = null) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) return this; return new UAuthSessionChain( @@ -109,12 +180,41 @@ public UAuthSessionChain Revoke(DateTimeOffset at) 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, - isRevoked: true, - revokedAt: at + RotationCount, + TouchCount, + SecurityVersionAtCreation, + revokedAt: now, + Version + 1 ); } @@ -123,25 +223,50 @@ internal static UAuthSessionChain FromProjection( SessionRootId rootId, TenantKey tenant, UserKey userKey, - int rotationCount, - long securityVersionAtCreation, + DateTimeOffset createdAt, + DateTimeOffset lastSeenAt, + DateTimeOffset? expiresAt, + DeviceContext device, ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, - bool isRevoked, - DateTimeOffset? revokedAt) + int rotationCount, + int touchCount, + long securityVersionAtCreation, + DateTimeOffset? revokedAt, + long version) { return new UAuthSessionChain( - chainId, - rootId, - tenant, - userKey, - rotationCount, - securityVersionAtCreation, - claimsSnapshot, - activeSessionId, - isRevoked, - revokedAt - ); + 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 index 3eb85942..0ae69287 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -1,73 +1,79 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSessionRoot +public sealed class UAuthSessionRoot : IVersionedEntity { public SessionRootId RootId { get; } - public UserKey UserKey { get; } public TenantKey Tenant { get; } + public UserKey UserKey { get; } + + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset? UpdatedAt { get; } + public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } + public long SecurityVersion { get; } - public IReadOnlyList Chains { get; } - public DateTimeOffset LastUpdatedAt { get; } + public long Version { get; set; } private UAuthSessionRoot( SessionRootId rootId, TenantKey tenant, UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, - IReadOnlyList chains, - DateTimeOffset lastUpdatedAt) + long version) { RootId = rootId; Tenant = tenant; UserKey = userKey; + CreatedAt = createdAt; + UpdatedAt = updatedAt; IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersion = securityVersion; - Chains = chains; - LastUpdatedAt = lastUpdatedAt; + Version = version; } public static UAuthSessionRoot Create( TenantKey tenant, UserKey userKey, - DateTimeOffset issuedAt) + DateTimeOffset at) { return new UAuthSessionRoot( SessionRootId.New(), tenant, userKey, - isRevoked: false, - revokedAt: null, - securityVersion: 0, - chains: Array.Empty(), - lastUpdatedAt: issuedAt + at, + null, + false, + null, + 0, + 0 ); } - public UAuthSessionRoot Revoke(DateTimeOffset at) + public UAuthSessionRoot IncreaseSecurityVersion(DateTimeOffset at) { - if (IsRevoked) - return this; - return new UAuthSessionRoot( RootId, Tenant, UserKey, - isRevoked: true, - revokedAt: at, - securityVersion: SecurityVersion, - chains: Chains, - lastUpdatedAt: at + CreatedAt, + at, + IsRevoked, + RevokedAt, + SecurityVersion + 1, + Version + 1 ); } - public UAuthSessionRoot AttachChain(UAuthSessionChain chain, DateTimeOffset at) + public UAuthSessionRoot Revoke(DateTimeOffset at) { if (IsRevoked) return this; @@ -76,11 +82,12 @@ public UAuthSessionRoot AttachChain(UAuthSessionChain chain, DateTimeOffset at) RootId, Tenant, UserKey, - IsRevoked, - RevokedAt, - SecurityVersion, - Chains.Concat(new[] { chain }).ToArray(), - at + CreatedAt, + at, + true, + at, + SecurityVersion + 1, + Version + 1 ); } @@ -88,23 +95,23 @@ internal static UAuthSessionRoot FromProjection( SessionRootId rootId, TenantKey tenant, UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, - IReadOnlyList chains, - DateTimeOffset lastUpdatedAt) + long version) { return new UAuthSessionRoot( rootId, tenant, userKey, + createdAt, + updatedAt, isRevoked, revokedAt, securityVersion, - chains, - lastUpdatedAt + version ); } - - } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs index 31cb67eb..bd21a678 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.ComponentModel.DataAnnotations.Schema; namespace CodeBeam.UltimateAuth.Core.Domain; @@ -7,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; /// Represents a persisted refresh token bound to a session. /// Stored as a hashed value for security reasons. /// -public sealed record StoredRefreshToken +public sealed record StoredRefreshToken : IVersionedEntity { public string TokenHash { get; init; } = default!; @@ -24,6 +25,8 @@ public sealed record StoredRefreshToken public string? ReplacedByTokenHash { get; init; } + public long Version { get; set; } + [NotMapped] public bool IsRevoked => RevokedAt.HasValue; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs index 6e042d46..8629eb13 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs @@ -4,6 +4,7 @@ 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/Base/UAuthAuthorizationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs deleted file mode 100644 index b4f1ad19..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthAuthorizationException : UAuthRuntimeException -{ - public UAuthAuthorizationException(string? reason = null) - : base(code: "forbidden", message: reason ?? "The current principal is not authorized to perform this operation.") - { - } -} 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/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/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs deleted file mode 100644 index 7adef0fa..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthNotFoundException : UAuthRuntimeException -{ - public UAuthNotFoundException(string code) : base(code, "Resource not found.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs index 51485596..49ec65db 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using System.Security.Claims; @@ -9,7 +10,7 @@ public static class ClaimsSnapshotExtensions /// /// Converts a ClaimsSnapshot into an ASP.NET Core ClaimsPrincipal. /// - public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = "UltimateAuth") + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = UAuthConstants.SchemeDefaults.GlobalScheme) { if (snapshot == null) return new ClaimsPrincipal(new ClaimsIdentity()); diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 18bebe32..f0c18bf8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -29,13 +29,24 @@ public sealed class UAuthLoginOptions /// /// 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. + /// 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; + internal UAuthLoginOptions Clone() => new() { MaxFailedAttempts = MaxFailedAttempts, LockoutDuration = LockoutDuration, - IncludeFailureDetails = IncludeFailureDetails + IncludeFailureDetails = IncludeFailureDetails, + FailureWindow = FailureWindow, + ExtendLockOnFailure = ExtendLockOnFailure }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 007cad76..1514da03 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -59,10 +59,6 @@ public sealed class UAuthSessionOptions /// /// Maximum number of session rotations within a single chain. /// Used for cleanup, replay protection, and analytics. - /// - /// NOTE: - /// Enforcement is not active in v0.0.1. - /// This option is reserved for future security policies. /// public int MaxSessionsPerChain { get; set; } = 100; diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index 11e5e962..b364a558 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions; public interface ICredentialResponseWriter { - void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId); - void Write(HttpContext context, CredentialKind kind, AccessToken accessToken); - void Write(HttpContext context, CredentialKind kind, RefreshToken refreshToken); + void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId); + void Write(HttpContext context, GrantKind kind, AccessToken accessToken); + void Write(HttpContext context, GrantKind kind, RefreshToken refreshToken); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs index 52d54c7e..d4bfbfca 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs @@ -5,5 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions; public interface IPrimaryCredentialResolver { - PrimaryCredentialKind Resolve(HttpContext context); + PrimaryGrantKind Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs index 84bdbcb1..76e977f3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs @@ -6,13 +6,15 @@ namespace CodeBeam.UltimateAuth.Server.Auth { internal sealed class AuthStateSnapshotFactory : IAuthStateSnapshotFactory { - private readonly IPrimaryUserIdentifierProvider _identifierProvider; - private readonly IUserProfileSnapshotProvider _profileSnapshotProvider; + private readonly IPrimaryUserIdentifierProvider _identifier; + private readonly IUserProfileSnapshotProvider _profile; + private readonly IUserLifecycleSnapshotProvider _lifecycle; - public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvider, IUserProfileSnapshotProvider profileSnapshotProvider) + public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifier, IUserProfileSnapshotProvider profile, IUserLifecycleSnapshotProvider lifecycle) { - _identifierProvider = identifierProvider; - _profileSnapshotProvider = profileSnapshotProvider; + _identifier = identifier; + _profile = profile; + _lifecycle = lifecycle; } public async Task CreateAsync(SessionValidationResult validation, CancellationToken ct = default) @@ -20,8 +22,9 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvide if (!validation.IsValid || validation.UserKey is null) return null; - var identifiers = await _identifierProvider.GetAsync(validation.Tenant, validation.UserKey.Value, ct); - var profile = await _profileSnapshotProvider.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + 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 { @@ -33,7 +36,8 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvide DisplayName = profile?.DisplayName, TimeZone = profile?.TimeZone, AuthenticatedAt = validation.AuthenticatedAt, - SessionState = validation.State + SessionState = validation.State, + UserStatus = lifecycle?.Status ?? UserStatus.Unknown }; return new AuthStateSnapshot diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs index 6cbb1fad..067810f4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs index cd5c4e55..df660fde 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -45,8 +45,10 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, if (authFlow.IsAuthenticated && authFlow.UserKey is not null) { - var roles = await _roleStore.GetRolesAsync(authFlow.Tenant, authFlow.UserKey.Value, ct); - attrs["roles"] = roles; + var assignments = await _roleStore.GetAssignmentsAsync(authFlow.Tenant, authFlow.UserKey.Value, ct); + var roleIds = assignments.Select(x => x.RoleId).ToArray(); + + attrs["roles"] = roleIds; } UserKey? targetUserKey = null; @@ -67,6 +69,7 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, actorTenant: authFlow.Tenant, isAuthenticated: authFlow.IsAuthenticated, isSystemActor: authFlow.Tenant.IsSystem, + actorChainId: authFlow.Session?.ChainId, resource: resource, targetUserKey: targetUserKey, resourceTenant: resourceTenant, diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs index cc3da3d8..acf78ac1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs @@ -26,21 +26,21 @@ private static UAuthResponseOptions PureOpaque(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie, }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, @@ -62,21 +62,21 @@ private static UAuthResponseOptions Hybrid(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie }, @@ -98,21 +98,21 @@ private static UAuthResponseOptions SemiHybrid(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, @@ -134,21 +134,21 @@ private static UAuthResponseOptions PureJwt(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs index 8ae5fcbd..7d5bedd6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -54,9 +54,9 @@ private static CredentialResponseOptions Bind(CredentialResponseOptions delivery var cookie = delivery.Kind switch { - CredentialKind.Session => server.Cookie.Session, - CredentialKind.AccessToken => server.Cookie.AccessToken, - CredentialKind.RefreshToken => server.Cookie.RefreshToken, + GrantKind.Session => server.Cookie.Session, + GrantKind.AccessToken => server.Cookie.AccessToken, + GrantKind.RefreshToken => server.Cookie.RefreshToken, _ => throw new InvalidOperationException($"Unsupported credential kind: {delivery.Kind}") }; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs index a9726933..cb64e072 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -14,9 +14,9 @@ public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProf return new UAuthResponseOptions { - SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, CredentialKind.Session, clientProfile), - AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, CredentialKind.AccessToken, clientProfile), - RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, CredentialKind.RefreshToken, clientProfile), + 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) @@ -27,7 +27,7 @@ public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProf // 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, CredentialKind kind, UAuthClientProfile clientProfile) + private static CredentialResponseOptions AdaptCredential(CredentialResponseOptions original, GrantKind kind, UAuthClientProfile clientProfile) { if (clientProfile == UAuthClientProfile.Maui && original.Mode == TokenResponseMode.Cookie) { diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs similarity index 79% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs index 59463779..eabcc607 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Authentication; namespace CodeBeam.UltimateAuth.Server.Authentication; @@ -7,7 +7,7 @@ public static class UAuthAuthenticationExtensions { public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder builder, Action? configure = null) { - return builder.AddScheme(UAuthSchemeDefaults.AuthenticationScheme, + return builder.AddScheme(UAuthConstants.SchemeDefaults.GlobalScheme, options => { configure?.Invoke(options); diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs similarity index 95% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs index 4072266a..bb5fded8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; @@ -73,8 +72,8 @@ protected override async Task HandleAuthenticateAsync() if (snapshot is null || snapshot.Identity is null) return AuthenticateResult.NoResult(); - var principal = snapshot.ToClaimsPrincipal(UAuthSchemeDefaults.AuthenticationScheme); - return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthSchemeDefaults.AuthenticationScheme)); + var principal = snapshot.ToClaimsPrincipal(UAuthConstants.SchemeDefaults.GlobalScheme); + return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthConstants.SchemeDefaults.GlobalScheme)); } protected override Task HandleChallengeAsync(AuthenticationProperties properties) diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs new file mode 100644 index 00000000..5c531ec4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -0,0 +1,61 @@ +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 CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal sealed class AuthenticationSecurityManager : IAuthenticationSecurityManager +{ + private readonly IAuthenticationSecurityStateStore _store; + private readonly UAuthServerOptions _options; + + public AuthenticationSecurityManager(IAuthenticationSecurityStateStore store, IOptions options) + { + _store = store; + _options = options.Value; + } + + public async Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var state = await _store.GetAsync(tenant, 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 state = await _store.GetAsync(tenant, 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) + { + return _store.UpdateAsync(updated, expectedVersion, ct); + } + + public Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return _store.DeleteAsync(tenant, userKey, scope, credentialType, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs index db88f962..8370bce1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs +++ b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs @@ -15,7 +15,7 @@ public static IServiceCollection Build(this UltimateAuthServerBuilder builder) //if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(IUAuthUserStore<>)))) // throw new InvalidOperationException("No credential store registered."); - if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStoreKernel)))) + 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/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs index 1d7a15d0..98c509f8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Contracts; public sealed record ResolvedCredential { - public PrimaryCredentialKind Kind { get; init; } + public PrimaryGrantKind Kind { get; init; } /// /// Raw credential value (session id / jwt / opaque) diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs deleted file mode 100644 index 8220ca7e..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Defaults; - -public static class UAuthActions -{ - public static class Users - { - public const string Create = "users.create"; - 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 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 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 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 ActivateAdmin = "credentials.activate.admin"; - public const string BeginResetSelf = "credentials.beginreset.self"; - public const string BeginResetAdmin = "credentials.beginreset.admin"; - public const string CompleteResetSelf = "credentials.completereset.self"; - public const string CompleteResetAdmin = "credentials.completereset.admin"; - public const string DeleteAdmin = "credentials.delete.admin"; - } - - public static class Authorization - { - public static class Roles - { - public const string ReadSelf = "authorization.roles.read.self"; - public const string ReadAdmin = "authorization.roles.read.admin"; - public const string AssignAdmin = "authorization.roles.assign.admin"; - public const string RemoveAdmin = "authorization.roles.remove.admin"; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs deleted file mode 100644 index bfcea56c..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Defaults; - -public static class UAuthSchemeDefaults -{ - public const string AuthenticationScheme = "UltimateAuth"; -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs index 2844105f..43c65375 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints; @@ -10,4 +11,10 @@ public interface IAuthorizationEndpointHandler 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 index a788a7b5..1e766bf0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -7,16 +7,16 @@ public interface ICredentialEndpointHandler { Task GetAllAsync(HttpContext ctx); Task AddAsync(HttpContext ctx); - Task ChangeAsync(string type, HttpContext ctx); - Task RevokeAsync(string type, HttpContext ctx); - Task BeginResetAsync(string type, HttpContext ctx); - Task CompleteResetAsync(string type, 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 RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task CompleteResetAdminAsync(UserKey userKey, string type, 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/ILogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs index 424560f2..583229f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs @@ -1,8 +1,16 @@ -using Microsoft.AspNetCore.Http; +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/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/ISessionManagementHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs deleted file mode 100644 index a4bcd598..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints; - -public interface ISessionManagementHandler -{ - Task GetCurrentSessionAsync(HttpContext ctx); - Task GetAllSessionsAsync(HttpContext ctx); - Task RevokeSessionAsync(string sessionId, HttpContext ctx); - Task RevokeAllAsync(HttpContext ctx); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs index e543a959..8e43a97b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -5,9 +5,12 @@ 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); @@ -17,6 +20,7 @@ public interface IUserEndpointHandler 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); @@ -25,6 +29,7 @@ public interface IUserEndpointHandler 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); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 4a499d2d..e3b0f3e6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -58,7 +58,6 @@ public async Task LoginAsync(HttpContext ctx) Secret = secret, Tenant = authFlow.Tenant, At = _clock.UtcNow, - Device = authFlow.Device, RequestTokens = authFlow.AllowsTokenIssuance }; @@ -75,17 +74,17 @@ public async Task LoginAsync(HttpContext ctx) if (result.SessionId is AuthSessionId sessionId) { - _credentialResponseWriter.Write(ctx, CredentialKind.Session, sessionId); + _credentialResponseWriter.Write(ctx, GrantKind.Session, sessionId); } if (result.AccessToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (result.RefreshToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index 15d01de7..4c04143d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -1,10 +1,14 @@ 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; @@ -13,14 +17,18 @@ 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, IClock clock, IUAuthCookieManager cookieManager, 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; @@ -53,6 +61,120 @@ public async Task LogoutAsync(HttpContext ctx) : 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) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 195ae018..b5bbd9e3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -129,7 +129,6 @@ public async Task CompleteAsync(HttpContext ctx) Secret = request.Secret, Tenant = authContext.Tenant, At = _clock.UtcNow, - Device = authContext.Device, RequestTokens = authContext.AllowsTokenIssuance }; @@ -145,17 +144,17 @@ public async Task CompleteAsync(HttpContext ctx) if (result.SessionId is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + _credentialResponseWriter.Write(ctx, GrantKind.Session, result.SessionId.Value); } if (result.AccessToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (result.RefreshToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } var decision = _redirectResolver.ResolveSuccess(authContext, ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index d4c3df38..1d3c3f3a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -59,18 +59,18 @@ public async Task RefreshAsync(HttpContext ctx) var primary = _refreshPolicy.SelectPrimary(flow, request, result); - if (primary == CredentialKind.Session && result.SessionId is not null) + if (primary == GrantKind.Session && result.SessionId is not null) { - _credentialWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + _credentialWriter.Write(ctx, GrantKind.Session, result.SessionId.Value); } - else if (primary == CredentialKind.AccessToken && result.AccessToken is not null) + else if (primary == GrantKind.AccessToken && result.AccessToken is not null) { - _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (_refreshPolicy.WriteRefreshToken(flow) && result.RefreshToken is not null) { - _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } if (flow.OriginalOptions.Diagnostics.EnableRefreshDetails) 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 index 929cd276..0fc21b33 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -1,10 +1,14 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +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; @@ -12,6 +16,13 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; // 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, @@ -28,7 +39,11 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.AddEndpointFilter(); - if (options.Endpoints.Login != false) + //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)); @@ -36,14 +51,40 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)); - group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + 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) @@ -57,43 +98,73 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); } - if (options.Endpoints.Token != false) - { - var token = group.MapGroup(""); + //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("/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("/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("/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)); - } + // token.MapPost("/revoke", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); + //} if (options.Endpoints.Session != false) { - var session = group.MapGroup("/session"); + var selfSession = self.MapGroup("/sessions"); + var adminSession = admin.MapGroup("/users/{userKey}/sessions"); - session.MapPost("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetCurrentSessionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + if (Enabled(UAuthActions.Sessions.ListChainsSelf)) + selfSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); - session.MapPost("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetAllSessionsAsync(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)); - session.MapPost("/revoke/{sessionId}", async ([FromServices] ISessionManagementHandler h, string sessionId, HttpContext ctx) - => await h.RevokeSessionAsync(sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + 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)); - session.MapPost("/revoke-all", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.RevokeAllAsync(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)); - //var user = group.MapGroup(""); - var users = group.MapGroup("/users"); - var adminUsers = group.MapGroup("/admin/users"); + 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) //{ @@ -107,149 +178,216 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); //} + var adminUsers = admin.MapGroup("/users"); + if (options.Endpoints.UserLifecycle != false) { - users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Users.CreateAnonymous)) + group.MapPost("/users/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Users.ChangeStatusSelf)) + self.MapPost("/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - // Post is intended for Auth - adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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) { - users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserProfiles.GetSelf)) + self.MapPost("/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - users.MapPost("/me/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserProfiles.UpdateSelf)) + self.MapPost("/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + + 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)); - adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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) { - users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.GetSelf)) + self.MapPost("/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.AddSelf)) + self.MapPost("/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.UpdateSelf)) + self.MapPost("/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + 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)); - users.MapPost("/me/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + 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)); - users.MapPost("/me/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.VerifySelf)) + self.MapPost("/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.DeleteSelf)) + self.MapPost("/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + 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 credentials = group.MapGroup("/credentials"); - var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); + var selfCredentials = self.MapGroup("/credentials"); + var adminCredentials = admin.MapGroup("/users/{userKey}/credentials"); - credentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.GetAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - - credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Credentials.AddSelf)) + selfCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/change", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - - credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.RevokeAsync(type, 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)); - credentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.BeginResetAsync(type, 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)); - credentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.CompleteResetAsync(type, 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)); - adminCredentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetAllAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - adminCredentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.RevokeAdminAsync(userKey, type, 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)); - adminCredentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.ActivateAdminAsync(userKey, type, 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)); - adminCredentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.BeginResetAdminAsync(userKey, type, 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)); - adminCredentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.CompleteResetAdminAsync(userKey, type, 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)); - adminCredentials.MapPost("/{type}/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.DeleteAdminAsync(userKey, type, 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 authz = group.MapGroup("/authorization"); - var adminAuthz = group.MapGroup("/admin/authorization"); + var selfAuthz = self.MapGroup("/authorization"); + var adminAuthz = admin.MapGroup("/authorization"); - authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + // TODO: Add enabled actions here + selfAuthz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - authz.MapPost("/users/me/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Authorization.Roles.ReadSelf)) + selfAuthz.MapPost("/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.Authorization.Roles.ReadAdmin)) + adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - adminAuthz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + 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)); - adminAuthz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + 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: diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index b8f09be6..808c8717 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -46,7 +46,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken ); } - if (credential.Kind == PrimaryCredentialKind.Stateful) + if (credential.Kind == PrimaryGrantKind.Stateful) { if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) { 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/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs index d664f3a1..890cdf28 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs similarity index 83% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs index 08338fc3..3cf61e3a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs index d1b57a00..69c58757 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs index 9936b9e6..ac88c5d7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs deleted file mode 100644 index c429e7c5..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -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); - - public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) - { - if (!ctx.Request.HasJsonContentType()) - throw new InvalidOperationException("Request content type must be application/json."); - - if (ctx.Request.Body is null) - throw new InvalidOperationException("Request body is empty."); - - var result = await JsonSerializer.DeserializeAsync(ctx.Request.Body, JsonOptions, ct); - - if (result is null) - throw new InvalidOperationException("Request body could not be deserialized."); - - return result; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 4c55fe37..b4646a45 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; @@ -16,13 +17,13 @@ using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Authentication; -using CodeBeam.UltimateAuth.Server.Defaults; 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.Runtime; +using CodeBeam.UltimateAuth.Server.Security; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; using CodeBeam.UltimateAuth.Users.Contracts; @@ -31,6 +32,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using CodeBeam.UltimateAuth.Users; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -70,6 +72,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddScoped(sp => @@ -136,9 +139,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddSingleton(); @@ -190,6 +193,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -202,12 +206,14 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddSingleton(); services.TryAddScoped(); + services.TryAddSingleton(); services.TryAddScoped(); @@ -217,19 +223,11 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddSingleton(); - //services.TryAddScoped>(); + services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped>(); services.TryAddScoped(); - - //services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped>(); services.TryAddScoped(); // ------------------------------ @@ -239,9 +237,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.PostConfigureAll(options => { - options.DefaultAuthenticateScheme ??= UAuthSchemeDefaults.AuthenticationScheme; - options.DefaultSignInScheme ??= UAuthSchemeDefaults.AuthenticationScheme; - options.DefaultChallengeScheme ??= UAuthSchemeDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; + options.DefaultSignInScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; + options.DefaultChallengeScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; }); services.AddAuthentication().AddUAuthCookies(); @@ -252,7 +250,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.Configure(opt => { - opt.AllowedBuiltIns = new HashSet + opt.AllowedTypes = new HashSet { UserIdentifierType.Username, UserIdentifierType.Email @@ -302,6 +300,7 @@ internal static IServiceCollection AddUsersInternal(IServiceCollection services) { services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs index a41344b2..29a97238 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -18,7 +18,7 @@ public LoginDecision Decide(LoginDecisionContext context) var state = context.SecurityState; if (state is not null) { - if (state.IsLocked) + if (state.IsLocked(DateTimeOffset.UtcNow)) return LoginDecision.Deny(AuthFailureReason.LockedOut); if (state.RequiresReauthentication) diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs index 72a53af6..c88a9aa4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Core.Security; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -33,7 +33,8 @@ public sealed class LoginDecisionContext /// /// Gets the user security state if the user could be resolved. /// - public IUserSecurityState? SecurityState { get; init; } + //public IUserSecurityState? SecurityState { get; init; } + public AuthenticationSecurityState? SecurityState { get; init; } /// /// Indicates whether the user exists. diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 4486152b..694def16 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -2,7 +2,9 @@ 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; @@ -26,8 +28,8 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator private readonly ISessionOrchestrator _sessionOrchestrator; private readonly ITokenIssuer _tokens; private readonly IUserClaimsProvider _claimsProvider; - private readonly IUserSecurityStateWriter _securityWriter; - private readonly IUserSecurityStateProvider _securityStateProvider; // runtime risk + private readonly ISessionStoreFactory _storeFactory; + private readonly IAuthenticationSecurityManager _authenticationSecurityManager; // runtime risk private readonly UAuthEventDispatcher _events; private readonly UAuthServerOptions _options; @@ -40,8 +42,8 @@ public LoginOrchestrator( ISessionOrchestrator sessionOrchestrator, ITokenIssuer tokens, IUserClaimsProvider claimsProvider, - IUserSecurityStateWriter securityWriter, - IUserSecurityStateProvider securityStateProvider, + ISessionStoreFactory storeFactory, + IAuthenticationSecurityManager authenticationSecurityManager, UAuthEventDispatcher events, IOptions options) { @@ -53,8 +55,8 @@ public LoginOrchestrator( _sessionOrchestrator = sessionOrchestrator; _tokens = tokens; _claimsProvider = claimsProvider; - _securityWriter = securityWriter; - _securityStateProvider = securityStateProvider; + _storeFactory = storeFactory; + _authenticationSecurityManager = authenticationSecurityManager; _events = events; _options = options.Value; } @@ -63,48 +65,43 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req { ct.ThrowIfCancellationRequested(); - var now = request.At ?? DateTimeOffset.UtcNow; + if (flow.Device.DeviceId is not DeviceId deviceId) + throw new UAuthConflictException("Device id could not resolved."); + var now = request.At ?? DateTimeOffset.UtcNow; var resolution = await _identifierResolver.ResolveAsync(request.Tenant, request.Identifier, ct); - var userKey = resolution?.UserKey; bool userExists = false; bool credentialsValid = false; - IUserSecurityState? securityState = null; - - DateTimeOffset? lockoutUntilUtc = null; - int? remainingAttempts = null; + AuthenticationSecurityState? accountState = null; + AuthenticationSecurityState? factorState = null; if (userKey is not null) { var user = await _users.GetAsync(request.Tenant, userKey.Value, ct); - if (user is not null && user.IsActive && !user.IsDeleted) + if (user is not null && user.CanAuthenticate && !user.IsDeleted) { userExists = true; - securityState = await _securityStateProvider.GetAsync(request.Tenant, userKey.Value, ct); + accountState = await _authenticationSecurityManager.GetOrCreateAccountAsync(request.Tenant, userKey.Value, ct); - if (securityState?.LastFailedAt is DateTimeOffset lastFail && _options.Login.FailureWindow is { } window && now - lastFail > window) + if (accountState.IsLocked(now)) { - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); - securityState = null; + return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0); } - if (securityState?.LockedUntil is DateTimeOffset until && until <= now) - { - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); - securityState = null; - } + factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(request.Tenant, userKey.Value, request.Factor, ct); - if (securityState?.LockedUntil is DateTimeOffset stillLocked && stillLocked > now) + if (factorState.IsLocked(now)) { - return LoginResult.Failed(AuthFailureReason.LockedOut, stillLocked, 0); + return LoginResult.Failed(AuthFailureReason.LockedOut, factorState.LockedUntil, 0); } var credentials = await _credentialStore.GetByUserAsync(request.Tenant, userKey.Value, ct); + // TODO: Add .Where(c => c.Type == request.Factor) when we support multiple factors per user foreach (var credential in credentials.OfType()) { if (!credential.Security.IsUsable(now)) @@ -121,6 +118,19 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } } + // TODO: Add create-time uniqueness guard for chain id for concurrency + var kernel = _storeFactory.Create(request.Tenant); + SessionChainId? chainId = null; + + if (userKey is not null) + { + var chain = await kernel.GetChainByDeviceAsync(request.Tenant, 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 = request.Tenant, @@ -128,8 +138,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req CredentialsValid = credentialsValid, UserExists = userExists, UserKey = userKey, - SecurityState = securityState, - IsChained = request.ChainId is not null + SecurityState = factorState, + IsChained = chainId is not null }; var decision = _authority.Decide(decisionContext); @@ -138,46 +148,40 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (decision.Kind == LoginDecisionKind.Deny) { - if (userKey is not null && userExists) + if (userKey is not null && userExists && factorState is not null) { - var isCurrentlyLocked = - securityState?.IsLocked == true && - securityState?.LockedUntil is DateTimeOffset until && - until > now; + var securityVersion = factorState.SecurityVersion; + factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure); + await _authenticationSecurityManager.UpdateAsync(factorState, securityVersion, ct); - if (!isCurrentlyLocked) - { - await _securityWriter.RecordFailedLoginAsync(request.Tenant, userKey.Value, now, ct); - - var currentFailures = securityState?.FailedLoginAttempts ?? 0; - var nextCount = currentFailures + 1; + DateTimeOffset? lockedUntil = null; + int? remainingAttempts = null; - if (max > 0) + if (_options.Login.IncludeFailureDetails) + { + if (factorState.IsLocked(now)) { - if (nextCount >= max) - { - lockoutUntilUtc = now.Add(_options.Login.LockoutDuration); - await _securityWriter.LockUntilAsync(request.Tenant, userKey.Value, lockoutUntilUtc.Value, ct); - remainingAttempts = 0; - - return LoginResult.Failed(AuthFailureReason.LockedOut, lockoutUntilUtc, remainingAttempts); - } - else - { - remainingAttempts = max - nextCount; - } + lockedUntil = factorState.LockedUntil; + remainingAttempts = 0; + } + else if (_options.Login.MaxFailedAttempts > 0) + { + remainingAttempts = _options.Login.MaxFailedAttempts - factorState.FailedAttempts; } } - else - { - lockoutUntilUtc = securityState!.LockedUntil; - remainingAttempts = 0; - } + + return LoginResult.Failed( + factorState.IsLocked(now) + ? AuthFailureReason.LockedOut + : decision.FailureReason, + lockedUntil, + remainingAttempts); } - return LoginResult.Failed(decision.FailureReason, lockoutUntilUtc, remainingAttempts); + return LoginResult.Failed(decision.FailureReason); } + if (decision.Kind == LoginDecisionKind.Challenge) { return LoginResult.Continue(new LoginContinuation @@ -190,7 +194,12 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Failed(AuthFailureReason.InvalidCredentials); // After this point, the login is successful. We can reset any failure counts and proceed to create a session. - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); + if (factorState is not null) + { + var version = factorState.SecurityVersion; + factorState = factorState.RegisterSuccess(); + await _authenticationSecurityManager.UpdateAsync(factorState, version, ct); + } var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, userKey.Value, ct); @@ -199,9 +208,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Tenant = request.Tenant, UserKey = userKey.Value, Now = now, - Device = request.Device, + Device = flow.Device, Claims = claims, - ChainId = request.ChainId, + ChainId = chainId, Metadata = SessionMetadata.Empty, Mode = flow.EffectiveMode }; @@ -218,7 +227,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Tenant = request.Tenant, UserKey = userKey.Value, SessionId = issuedSession.Session.SessionId, - ChainId = request.ChainId, + ChainId = issuedSession.Session.ChainId, Claims = claims.AsDictionary() }; @@ -230,7 +239,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } await _events.DispatchAsync( - new UserLoggedInContext(request.Tenant, userKey.Value, now, request.Device, issuedSession.Session.SessionId)); + new UserLoggedInContext(request.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId)); return LoginResult.Success(issuedSession.Session.SessionId, tokens); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs index fbb34c10..9d8e1515 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs @@ -6,6 +6,6 @@ namespace CodeBeam.UltimateAuth.Server.Flows; public interface IRefreshResponsePolicy { - CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); + GrantKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); bool WriteRefreshToken(AuthFlowContext flow); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs index 6e909cdb..e29bc768 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs @@ -8,28 +8,28 @@ namespace CodeBeam.UltimateAuth.Server.Flows; internal class RefreshResponsePolicy : IRefreshResponsePolicy { - public CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) + public GrantKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) { if (flow.EffectiveMode == UAuthMode.PureOpaque) - return CredentialKind.Session; + return GrantKind.Session; if (flow.EffectiveMode == UAuthMode.PureJwt) - return CredentialKind.AccessToken; + return GrantKind.AccessToken; if (!string.IsNullOrWhiteSpace(request.RefreshToken) && request.SessionId == null) { - return CredentialKind.AccessToken; + return GrantKind.AccessToken; } if (request.SessionId != null) { - return CredentialKind.Session; + return GrantKind.Session; } if (flow.ClientProfile == UAuthClientProfile.Api) - return CredentialKind.AccessToken; + return GrantKind.AccessToken; - return CredentialKind.Session; + return GrantKind.Session; } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs index 8ff47bc9..324d70ec 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs index 5df517b3..2b29016e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs @@ -5,9 +5,9 @@ namespace CodeBeam.UltimateAuth.Server.Flows; public sealed class SessionTouchService : ISessionTouchService { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _kernelFactory; - public SessionTouchService(ISessionStoreKernelFactory kernelFactory) + public SessionTouchService(ISessionStoreFactory kernelFactory) { _kernelFactory = kernelFactory; } @@ -16,11 +16,11 @@ public SessionTouchService(ISessionStoreKernelFactory kernelFactory) // 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.SessionId is null) + if (!validation.IsValid || validation.ChainId is null) return SessionRefreshResult.ReauthRequired(); if (!policy.TouchInterval.HasValue) - return SessionRefreshResult.Success(validation.SessionId.Value, didTouch: false); + return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch: false); var kernel = _kernelFactory.Create(validation.Tenant); @@ -28,18 +28,21 @@ public async Task RefreshAsync(SessionValidationResult val await kernel.ExecuteAsync(async _ => { - var session = await kernel.GetSessionAsync(validation.SessionId.Value); - if (session is null || session.IsRevoked) + var chain = await kernel.GetChainAsync(validation.ChainId.Value); + + if (chain is null || chain.IsRevoked) return; - if (sessionTouchMode == SessionTouchMode.IfNeeded && now - session.LastSeenAt < policy.TouchInterval.Value) + if (now - chain.LastSeenAt < policy.TouchInterval.Value) return; - var touched = session.Touch(now); - await kernel.SaveSessionAsync(touched); + var expectedVersion = chain.Version; + var touched = chain.Touch(now); + + await kernel.SaveChainAsync(touched, expectedVersion); didTouch = true; }, ct); - return SessionRefreshResult.Success(validation.SessionId.Value, didTouch); + return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs index 18d3f160..786498ed 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs @@ -7,5 +7,5 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IUAuthCookiePolicyBuilder { - CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind); + CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, GrantKind kind); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs index 9d75487a..78b0af0a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class UAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder { - public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind) + 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."); @@ -43,7 +43,7 @@ private static SameSiteMode ResolveSameSite(UAuthCookieOptions cookie, AuthFlowC }; } - private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, AuthFlowContext context, CredentialKind kind) + 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); @@ -54,7 +54,7 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, } } - private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, CredentialKind kind, UAuthCookieOptions src) + private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, GrantKind kind, UAuthCookieOptions src) { if (src.MaxAge is not null) return src.MaxAge; @@ -64,9 +64,9 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, return kind switch { - CredentialKind.Session => ResolveSessionLifetime(context), - CredentialKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, - CredentialKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, + GrantKind.Session => ResolveSessionLifetime(context), + GrantKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, + GrantKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, _ => null }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index add44279..6c43139c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -27,16 +27,16 @@ public CredentialResponseWriter( _headerPolicy = headerPolicy; } - public void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId) + public void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId) => WriteInternal(context, kind, sessionId.ToString()); - public void Write(HttpContext context, CredentialKind kind, AccessToken token) + public void Write(HttpContext context, GrantKind kind, AccessToken token) => WriteInternal(context, kind, token.Token); - public void Write(HttpContext context, CredentialKind kind, RefreshToken token) + public void Write(HttpContext context, GrantKind kind, RefreshToken token) => WriteInternal(context, kind, token.Token); - public void WriteInternal(HttpContext context, CredentialKind kind, string value) + public void WriteInternal(HttpContext context, GrantKind kind, string value) { var auth = _authContext.Current; var delivery = ResolveDelivery(auth.Response, kind); @@ -61,7 +61,7 @@ public void WriteInternal(HttpContext context, CredentialKind kind, string value } } - private void WriteCookie(HttpContext context, CredentialKind kind, string value, CredentialResponseOptions options, AuthFlowContext auth) + 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}'."); @@ -78,12 +78,12 @@ private void WriteHeader(HttpContext context, string value, CredentialResponseOp context.Response.Headers[headerName] = formatted; } - private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse response, CredentialKind kind) + private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse response, GrantKind kind) => kind switch { - CredentialKind.Session => response.SessionIdDelivery, - CredentialKind.AccessToken => response.AccessTokenDelivery, - CredentialKind.RefreshToken => response.RefreshTokenDelivery, + 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/FlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs index ffe1092d..8aa3f572 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs @@ -23,8 +23,8 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return kind switch { - PrimaryCredentialKind.Stateful => ResolveSession(context, response), - PrimaryCredentialKind.Stateless => ResolveAccessToken(context, response), + PrimaryGrantKind.Stateful => ResolveSession(context, response), + PrimaryGrantKind.Stateless => ResolveAccessToken(context, response), _ => null }; @@ -49,7 +49,7 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return new ResolvedCredential { - Kind = PrimaryCredentialKind.Stateful, + Kind = PrimaryGrantKind.Stateful, Value = raw.Trim(), Tenant = context.GetTenant(), Device = context.GetDevice() @@ -81,7 +81,7 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return new ResolvedCredential { - Kind = PrimaryCredentialKind.Stateless, + Kind = PrimaryGrantKind.Stateless, Value = value, Tenant = context.GetTenant(), Device = context.GetDevice() diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs index 595f7aae..3ba31678 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs @@ -16,7 +16,7 @@ public PrimaryCredentialResolver(IOptions options) _options = options.Value; } - public PrimaryCredentialKind Resolve(HttpContext context) + public PrimaryGrantKind Resolve(HttpContext context) { if (IsApiRequest(context)) return _options.PrimaryCredential.Api; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs index 0a3a41b6..e1e7dccd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs @@ -7,9 +7,16 @@ internal sealed class DeviceContextFactory : IDeviceContextFactory { public DeviceContext Create(DeviceInfo device) { - if (string.IsNullOrWhiteSpace(device.DeviceId.Value)) + if (device is null || string.IsNullOrWhiteSpace(device.DeviceId.Value)) return DeviceContext.Anonymous(); - return DeviceContext.FromDeviceId(device.DeviceId); + 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 index 3ac067a1..dbcc0a50 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstractions; using Microsoft.AspNetCore.Http; @@ -7,6 +7,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; +// TODO: This is a very basic implementation. +// Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. (Add IDeviceInfoParser) public sealed class DeviceResolver : IDeviceResolver { public DeviceInfo Resolve(HttpContext context) @@ -14,17 +16,24 @@ public DeviceInfo Resolve(HttpContext context) var request = context.Request; var rawDeviceId = ResolveRawDeviceId(context); - DeviceId.TryCreate(rawDeviceId, out var deviceId); + if (!DeviceId.TryCreate(rawDeviceId, out var deviceId)) + { + //throw new InvalidOperationException("device_id_required"); + } - return new DeviceInfo + var ua = request.Headers.UserAgent.ToString(); + var deviceInfo = new DeviceInfo { DeviceId = deviceId, - Platform = ResolvePlatform(request), - UserAgent = request.Headers.UserAgent.ToString(), - IpAddress = context.Connection.RemoteIpAddress?.ToString() + Platform = ResolvePlatform(ua), + OperatingSystem = ResolveOperatingSystem(ua), + Browser = ResolveBrowser(ua), + UserAgent = ua, + IpAddress = ResolveIp(context) }; - } + return deviceInfo; + } private static string? ResolveRawDeviceId(HttpContext context) { @@ -42,16 +51,70 @@ public DeviceInfo Resolve(HttpContext context) return null; } - private static string? ResolvePlatform(HttpRequest request) + private static string? ResolvePlatform(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("ipad") || s.Contains("tablet") || s.Contains("sm-t") /* bazı samsung tabletler */) + return "tablet"; + + if (s.Contains("mobi") || s.Contains("iphone") || s.Contains("android")) + return "mobile"; + + return "desktop"; + } + + private static string? ResolveOperatingSystem(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("iphone") || s.Contains("ipad") || s.Contains("cpu os") || s.Contains("ios")) + return "ios"; + + if (s.Contains("android")) + return "android"; + + if (s.Contains("windows nt")) + return "windows"; + + if (s.Contains("mac os x") || s.Contains("macintosh")) + return "macos"; + + if (s.Contains("linux")) + return "linux"; + + return "unknown"; + } + + private static string? ResolveBrowser(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("edg/")) + return "edge"; + + if (s.Contains("opr/") || s.Contains("opera")) + return "opera"; + + if (s.Contains("chrome/") && !s.Contains("chromium/")) + return "chrome"; + + if (s.Contains("safari/") && !s.Contains("chrome/") && !s.Contains("crios/")) + return "safari"; + + if (s.Contains("firefox/")) + return "firefox"; + + return "unknown"; + } + + private static string? ResolveIp(HttpContext context) { - var ua = request.Headers.UserAgent.ToString().ToLowerInvariant(); + var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - 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 os")) return "macos"; - if (ua.Contains("linux")) return "linux"; + if (!string.IsNullOrWhiteSpace(forwarded)) + return forwarded.Split(',')[0].Trim(); - return "web"; + return context.Connection.RemoteIpAddress?.ToString(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/JwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/JwtTokenGenerator.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs 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/OpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs similarity index 79% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs index ba7d89f8..9dee1407 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs @@ -1,6 +1,7 @@ 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; @@ -17,5 +18,5 @@ public OpaqueTokenGenerator(IOptions options) public string Generate() => GenerateBytes(_options.OpaqueIdBytes); public string GenerateJwtId() => GenerateBytes(16); - private static string GenerateBytes(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)); + 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 index 31b36020..941c23d1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs @@ -24,12 +24,12 @@ public string Hash(string plaintext) return Convert.ToBase64String(hash); } - public bool Verify(string plaintext, string hash) + public bool Verify(string hash, string plaintext) { var computed = Hash(plaintext); return CryptographicOperations.FixedTimeEquals( - Convert.FromBase64String(computed), - Convert.FromBase64String(hash)); + Convert.FromBase64String(hash), + Convert.FromBase64String(computed)); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 4975109f..dc9ce475 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -2,6 +2,7 @@ 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; @@ -11,18 +12,18 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UAuthSessionIssuer : ISessionIssuer { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly UAuthServerOptions _options; - public UAuthSessionIssuer(ISessionStoreKernelFactory kernelFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) + public UAuthSessionIssuer(ISessionStoreFactory storeFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) { - _kernelFactory = kernelFactory; + _storeFactory = storeFactory; _opaqueGenerator = opaqueGenerator; _options = options.Value; } - public async Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) + public async Task IssueSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority if (context.Mode == UAuthMode.PureJwt) @@ -44,38 +45,36 @@ public async Task IssueLoginSessionAsync(AuthenticatedSessionCont expiresAt = absoluteExpiry; } - var session = UAuthSession.Create( - sessionId: sessionId, - tenant: context.Tenant, - userKey: context.UserKey, - chainId: SessionChainId.Unassigned, - now: now, - expiresAt: expiresAt, - claims: context.Claims, - device: context.Device, - metadata: context.Metadata - ); - - var issued = new IssuedSession - { - Session = session, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid - }; + var kernel = _storeFactory.Create(context.Tenant); - var kernel = _kernelFactory.Create(context.Tenant); + IssuedSession? issued = null; await kernel.ExecuteAsync(async _ => { - var root = await kernel.GetSessionRootByUserAsync(context.UserKey) - ?? UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); + 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) { chain = await kernel.GetChainAsync(context.ChainId.Value) - ?? throw new SecurityException("Chain not found."); + ?? 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 { @@ -84,26 +83,65 @@ await kernel.ExecuteAsync(async _ => root.RootId, context.Tenant, context.UserKey, - root.SecurityVersion, - ClaimsSnapshot.Empty); + now, + expiresAt, + context.Device, + ClaimsSnapshot.Empty, + root.SecurityVersion + ); + + await kernel.CreateChainAsync(chain); + } - await kernel.SaveChainAsync(chain); - root = root.AttachChain(chain, now); + 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 boundSession = session.WithChain(chain.ChainId); + var session = UAuthSession.Create( + sessionId: sessionId, + tenant: context.Tenant, + userKey: context.UserKey, + chainId: chain.ChainId, + now: now, + expiresAt: expiresAt, + securityVersion: root.SecurityVersion, + claims: context.Claims, + metadata: context.Metadata + ); + + await kernel.CreateSessionAsync(session); - await kernel.SaveSessionAsync(boundSession); - await kernel.SetActiveSessionIdAsync(chain.ChainId, boundSession.SessionId); - await kernel.SaveSessionRootAsync(root); + 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 = _kernelFactory.Create(context.Tenant); + var kernel = _storeFactory.Create(context.Tenant); var now = context.Now; var opaqueSessionId = _opaqueGenerator.Generate(); @@ -118,59 +156,93 @@ public async Task RotateSessionAsync(SessionRotationContext conte expiresAt = absoluteExpiry; } - var issued = new IssuedSession - { - Session = UAuthSession.Create( - sessionId: newSessionId, - tenant: context.Tenant, - userKey: context.UserKey, - chainId: SessionChainId.Unassigned, - now: now, - expiresAt: expiresAt, - device: context.Device, - claims: context.Claims, - metadata: context.Metadata - ), - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid - }; + 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"); - var bound = issued.Session.WithChain(chain.ChainId); + 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, + 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); - await kernel.SaveSessionAsync(bound); - await kernel.SetActiveSessionIdAsync(chain.ChainId, bound.SessionId); - await kernel.RevokeSessionAsync(oldSession.SessionId, now); + 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 = _kernelFactory.Create(tenant); + 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 = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeChainAsync(chainId, at), ct); + 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 = _kernelFactory.Create(tenant); + var kernel = _storeFactory.Create(tenant); await kernel.ExecuteAsync(async _ => { var chains = await kernel.GetChainsByUserAsync(userKey); @@ -180,21 +252,21 @@ await kernel.ExecuteAsync(async _ => if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) continue; - if (!chain.IsRevoked) - await kernel.RevokeChainAsync(chain.ChainId, at); - - var activeSessionId = await kernel.GetActiveSessionIdAsync(chain.ChainId); - if (activeSessionId is not null) - await kernel.RevokeSessionAsync(activeSessionId.Value, at); + await kernel.RevokeChainCascadeAsync(chain.ChainId, at); } }, ct); } - // TODO: Discuss revoking chains/sessions when root is revoked public async Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeSessionRootAsync(userKey, at), ct); - } + 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/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 index 795307ce..8252f34c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -7,6 +7,6 @@ internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext Log { public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { - return issuer.IssueLoginSessionAsync(LoginContext, ct); + return issuer.IssueSessionAsync(LoginContext, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs deleted file mode 100644 index 4fc9fcb5..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public sealed class RevokeAllUserSessionsCommand : ISessionCommand -{ - public UserKey UserKey { get; } - - public RevokeAllUserSessionsCommand(UserKey userKey) - { - UserKey = userKey; - } - - // TODO: This method should call its own logic. Not revoke root. - 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/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs index 094fa5f2..6fe26b2d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -1,5 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +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; @@ -9,22 +12,26 @@ public sealed class UAuthAccessOrchestrator : IAccessOrchestrator { private readonly IAccessAuthority _authority; private readonly IAccessPolicyProvider _policyProvider; + private readonly IUserPermissionStore _permissions; - public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider) + 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); + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); if (decision.RequiresReauthentication) throw new InvalidOperationException("Requires reauthentication."); @@ -36,15 +43,27 @@ public async Task ExecuteAsync(AccessContext context, IAccessC { 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); + 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 index bc55c658..190cb521 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -28,7 +28,7 @@ public async Task ExecuteAsync(AuthContext authContext, ISessi switch (decision.Decision) { case AuthorizationDecision.Deny: - throw new UAuthAuthorizationException(decision.Reason); + throw new UAuthAuthorizationException(decision.Reason ?? "authorization_denied"); case AuthorizationDecision.Challenge: throw new UAuthChallengeRequiredException(decision.Reason); @@ -39,5 +39,4 @@ public async Task ExecuteAsync(AuthContext authContext, ISessi return await command.ExecuteAsync(authContext, _issuer, ct); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs index 4c6764dc..da93972b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Infrastructure; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs index 6b933a91..64664ca5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs index 191aadf7..d02e74b3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Constants; 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; @@ -10,10 +10,10 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UAuthUserAccessor : IUserAccessor { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _kernelFactory; private readonly IUserIdConverter _userIdConverter; - public UAuthUserAccessor(ISessionStoreKernelFactory kernelFactory, IUserIdConverterResolver converterResolver) + public UAuthUserAccessor(ISessionStoreFactory kernelFactory, IUserIdConverterResolver converterResolver) { _kernelFactory = kernelFactory; _userIdConverter = converterResolver.GetConverter(); 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..21243698 --- /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, UserIdentifierDto identifier, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs new file mode 100644 index 00000000..8157aeee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUserCreateValidator +{ + Task ValidateAsync(AccessContext context, CreateUserRequest request, 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..3fb399b3 --- /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, UserIdentifierDto 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..b08b2150 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -0,0 +1,64 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +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 UserIdentifierDto() + { + Type = UserIdentifierType.Username, + Value = request.UserName + }, ct); + + errors.AddRange(r.Errors); + } + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + { + Type = UserIdentifierType.Email, + Value = request.Email + }, ct); + + errors.AddRange(r.Errors); + } + + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + { + 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/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index 13929078..7e7d94d3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index fef8b200..719f90b1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.MultiTenancy; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs index a62c5c7b..e1245421 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Options; public sealed class CredentialResponseOptions { - public CredentialKind Kind { get; init; } + public GrantKind Kind { get; init; } public TokenResponseMode Mode { get; set; } = TokenResponseMode.None; /// @@ -48,7 +48,7 @@ public CredentialResponseOptions WithCookie(UAuthCookieOptions cookie) }; } - public static CredentialResponseOptions Disabled(CredentialKind kind) + public static CredentialResponseOptions Disabled(GrantKind kind) => new() { Kind = kind, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs similarity index 91% rename from src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs index fd995ac8..1d7c9c6a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class UAuthUserIdentifierOptions +public sealed class UAuthIdentifierOptions { public bool AllowUsernameChange { get; set; } = true; public bool AllowMultipleUsernames { get; set; } = false; @@ -14,7 +14,7 @@ public sealed class UAuthUserIdentifierOptions public bool AllowAdminOverride { get; set; } = true; public bool AllowUserOverride { get; set; } = true; - internal UAuthUserIdentifierOptions Clone() => new() + internal UAuthIdentifierOptions Clone() => new() { AllowUsernameChange = AllowUsernameChange, AllowMultipleUsernames = AllowMultipleUsernames, 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 index 450ce64e..07df6483 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Options; public sealed class UAuthLoginIdentifierOptions { - public ISet AllowedBuiltIns { get; set; } = + public ISet AllowedTypes { get; set; } = new HashSet { UserIdentifierType.Username, @@ -17,12 +18,32 @@ public sealed class UAuthLoginIdentifierOptions 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() { - AllowedBuiltIns = new HashSet(AllowedBuiltIns), + 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/UAuthPrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs index 1da6d92d..685bd2a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs @@ -7,12 +7,12 @@ public sealed class UAuthPrimaryCredentialPolicy /// /// Default primary credential for UI-style requests. /// - public PrimaryCredentialKind Ui { get; set; } = PrimaryCredentialKind.Stateful; + public PrimaryGrantKind Ui { get; set; } = PrimaryGrantKind.Stateful; /// /// Default primary credential for API requests. /// - public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; + public PrimaryGrantKind Api { get; set; } = PrimaryGrantKind.Stateless; internal UAuthPrimaryCredentialPolicy Clone() => new() { 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/UAuthServerEndpointOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs index b586f272..ee34540f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs @@ -8,9 +8,9 @@ public sealed class UAuthServerEndpointOptions /// public string BasePath { get; set; } = "/auth"; - public bool Login { get; set; } = true; + public bool Authentication { get; set; } = true; public bool Pkce { get; set; } = true; - public bool Token { get; set; } = true; + //public bool Token { get; set; } = true; public bool Session { get; set; } = true; //public bool UserInfo { get; set; } = true; @@ -21,17 +21,22 @@ public sealed class UAuthServerEndpointOptions public bool Authorization { get; set; } = true; + public HashSet DisabledActions { get; set; } = new(); + + public bool IsDisabled(string action) => DisabledActions.Contains(action); + internal UAuthServerEndpointOptions Clone() => new() { - Login = Login, + Authentication = Authentication, Pkce = Pkce, - Token = Token, + //Token = Token, Session = Session, //UserInfo = UserInfo, UserLifecycle = UserLifecycle, UserProfile = UserProfile, UserIdentifier = UserIdentifier, Credentials = Credentials, - Authorization = Authorization + Authorization = Authorization, + DisabledActions = new HashSet(DisabledActions) }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 17dbebe1..34f41c83 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -74,6 +74,8 @@ public sealed class UAuthServerOptions public UAuthResponseOptions AuthResponse { get; init; } = new(); + public UAuthResetOptions ResetCredential { get; init; } = new(); + public UAuthHubServerOptions Hub { get; set; } = new(); /// @@ -87,7 +89,9 @@ public sealed class UAuthServerOptions /// public UAuthServerEndpointOptions Endpoints { get; set; } = new(); - public UAuthUserIdentifierOptions UserIdentifiers { get; set; } = new(); + public UAuthIdentifierOptions Identifiers { get; set; } = new(); + + public UAuthIdentifierValidationOptions IdentifierValidation { get; set; } = new(); public UAuthLoginIdentifierOptions LoginIdentifiers { get; set; } = new(); @@ -138,9 +142,11 @@ internal UAuthServerOptions Clone() PrimaryCredential = PrimaryCredential.Clone(), AuthResponse = AuthResponse.Clone(), + ResetCredential = ResetCredential.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), - UserIdentifiers = UserIdentifiers.Clone(), + Identifiers = Identifiers.Clone(), + IdentifierValidation = IdentifierValidation.Clone(), LoginIdentifiers = LoginIdentifiers.Clone(), Endpoints = Endpoints.Clone(), Navigation = Navigation.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs index 82af7684..3e8ce27b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs @@ -6,7 +6,7 @@ public sealed class UAuthServerUserIdentifierOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { - if (!options.UserIdentifiers.AllowAdminOverride && !options.UserIdentifiers.AllowUserOverride) + 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."); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs new file mode 100644 index 00000000..3078e595 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/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/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs new file mode 100644 index 00000000..8e6d6ad9 --- /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(SessionChainSummaryDto.ChainId) => request.Descending + ? chains.OrderByDescending(x => x.ChainId).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.CreatedAt) => request.Descending + ? chains.OrderByDescending(x => x.CreatedAt).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.LastSeenAt) => request.Descending + ? chains.OrderByDescending(x => x.LastSeenAt).ToList() + : chains.OrderBy(x => x.LastSeenAt).ToList(), + + nameof(SessionChainSummaryDto.RevokedAt) => request.Descending + ? chains.OrderByDescending(x => x.RevokedAt).ToList() + : chains.OrderBy(x => x.RevokedAt).ToList(), + + nameof(SessionChainSummaryDto.DeviceType) => request.Descending + ? chains.OrderByDescending(x => x.Device.DeviceType).ToList() + : chains.OrderBy(x => x.Device.DeviceType).ToList(), + + nameof(SessionChainSummaryDto.OperatingSystem) => request.Descending + ? chains.OrderByDescending(x => x.Device.OperatingSystem).ToList() + : chains.OrderBy(x => x.Device.OperatingSystem).ToList(), + + nameof(SessionChainSummaryDto.Platform) => request.Descending + ? chains.OrderByDescending(x => x.Device.Platform).ToList() + : chains.OrderBy(x => x.Device.Platform).ToList(), + + nameof(SessionChainSummaryDto.Browser) => request.Descending + ? chains.OrderByDescending(x => x.Device.Browser).ToList() + : chains.OrderBy(x => x.Device.Browser).ToList(), + + nameof(SessionChainSummaryDto.RotationCount) => request.Descending + ? chains.OrderByDescending(x => x.RotationCount).ToList() + : chains.OrderBy(x => x.RotationCount).ToList(), + + nameof(SessionChainSummaryDto.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 SessionChainSummaryDto + { + 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 SessionChainDetailDto + { + 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 SessionInfoDto( + 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 index 1ad74409..caafda9e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -31,21 +31,6 @@ public UAuthFlowService( _events = events; } - public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) { return _loginOrchestrator.LoginAsync(flow, request, ct); @@ -106,4 +91,19 @@ public Task ReauthenticateAsync(ReauthRequest request, Cancellatio { 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/UAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs deleted file mode 100644 index 7cd1b428..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs +++ /dev/null @@ -1,50 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -// TODO: Add wrapper service in client project. Validate method also may add. -namespace CodeBeam.UltimateAuth.Server.Services; - -internal sealed class UAuthSessionManager : IUAuthSessionManager -{ - private readonly IAuthFlowContextAccessor _authFlow; - private readonly ISessionOrchestrator _orchestrator; - private readonly IClock _clock; - - public UAuthSessionManager(IAuthFlowContextAccessor authFlow, ISessionOrchestrator orchestrator, IClock clock) - { - _authFlow = authFlow; - _orchestrator = orchestrator; - _clock = clock; - } - - public Task RevokeSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeSessionCommand(sessionId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeChainAsync(SessionChainId chainId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeChainCommand(chainId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeAllChainsCommand(userKey, exceptChainId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeRootAsync(UserKey userKey, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeRootCommand(userKey); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs index 04d34fe4..2e35a6cb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs @@ -6,11 +6,11 @@ namespace CodeBeam.UltimateAuth.Server.Services; public sealed class UAuthSessionQueryService : ISessionQueryService { - private readonly ISessionStoreKernelFactory _storeFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IAuthFlowContextAccessor _authFlow; public UAuthSessionQueryService( - ISessionStoreKernelFactory storeFactory, + ISessionStoreFactory storeFactory, IAuthFlowContextAccessor authFlow) { _storeFactory = storeFactory; @@ -37,7 +37,7 @@ public Task> GetChainsByUserAsync(UserKey userK return CreateKernel().GetChainIdBySessionAsync(sessionId); } - private ISessionStoreKernel CreateKernel() + 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 index 74abecdc..da89b7c9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -9,14 +9,11 @@ namespace CodeBeam.UltimateAuth.Server.Services; internal sealed class UAuthSessionValidator : ISessionValidator { - private readonly ISessionStoreKernelFactory _storeFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IUserClaimsProvider _claimsProvider; private readonly UAuthServerOptions _options; - public UAuthSessionValidator( - ISessionStoreKernelFactory storeFactory, - IUserClaimsProvider claimsProvider, - IOptions options) + public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) { _storeFactory = storeFactory; _claimsProvider = claimsProvider; @@ -33,27 +30,54 @@ public async Task ValidateSessionAsync(SessionValidatio if (session is null) return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); - var state = session.GetState(context.Now, _options.Session.IdleTimeout); + var state = session.GetState(context.Now); if (state != SessionState.Active) - return SessionValidationResult.Invalid(state, sessionId: session.SessionId, chainId: session.ChainId); + 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 root = await kernel.GetSessionRootByUserAsync(session.UserKey); + 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, session.UserKey, session.SessionId, session.ChainId, root?.RootId); + 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); - // TODO: Implement device id, AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. - // Currently this line has error on refresh flow. - //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) - // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); + 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, session.Device.DeviceId); + return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, chain.Device.DeviceId); } } 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..0ad38403 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj @@ -0,0 +1,16 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + \ 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..8c7e25ab --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs @@ -0,0 +1,79 @@ +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 ConcurrentDictionary _byId = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey, AuthenticationSecurityScope, CredentialType?), Guid> _index = new(); + + public Task GetAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_index.TryGetValue((tenant, userKey, scope, credentialType), out var id) && _byId.TryGetValue(id, out var state)) + { + return Task.FromResult(state); + } + + return Task.FromResult(null); + } + + public Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (state.Tenant, state.UserKey, state.Scope, state.CredentialType); + + if (!_index.TryAdd(key, state.Id)) + throw new UAuthConflictException("security_state_already_exists"); + + if (!_byId.TryAdd(state.Id, state)) + { + _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(); + + var key = (state.Tenant, 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"); + + if (!_byId.TryUpdate(state.Id, state, current)) + throw new UAuthConflictException("security_state_update_conflict"); + + return Task.CompletedTask; + } + + public Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenant, 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/ServiceCollectionExtensions.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..ab406e99 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Authentication.InMemory; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthInMemoryAuthenticationSecurity(this IServiceCollection services) + { + services.AddSingleton(); + + return services; + } +} 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..35626d73 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs @@ -0,0 +1,19 @@ +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; +} 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/PermissionDto.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs deleted file mode 100644 index 10baf330..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; - -public sealed record PermissionDto -{ - public required string Value { get; init; } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs deleted file mode 100644 index cc50b191..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; - -public sealed record RoleDto -{ - public required string Name { get; init; } -} 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/RoleQuery.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs new file mode 100644 index 00000000..f9515966 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class 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/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/Requests/CreateRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs new file mode 100644 index 00000000..539f7f86 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class CreateRoleRequest +{ + public string Name { get; set; } = default!; + public IEnumerable? Permissions { get; set; } +} 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..cdc013f3 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class DeleteRoleRequest +{ + public DeleteMode Mode { get; set; } +} 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..4d33d28e --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class RenameRoleRequest +{ + public string Name { get; set; } = default!; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs new file mode 100644 index 00000000..be5237b6 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class SetPermissionsRequest +{ + public IEnumerable Permissions { get; set; } = []; +} 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 index d345e84e..1acc9da8 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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 IReadOnlyCollection Roles { get; init; } + public required PagedResult Roles { get; init; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs index d4698be1..b62ad6b2 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -8,6 +9,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) { + services.TryAddSingleton(); services.TryAddSingleton(); // Never try add - seeding is enumerated and all contributors are added. diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs index 7bc3b7ac..5cb76c78 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -9,22 +11,32 @@ internal sealed class InMemoryAuthorizationSeedContributor : ISeedContributor { public int Order => 20; + private readonly IRoleStore _roleStore; private readonly IUserRoleStore _roles; private readonly IInMemoryUserIdProvider _ids; + private readonly IClock _clock; - public InMemoryAuthorizationSeedContributor(IUserRoleStore roles, IInMemoryUserIdProvider ids) + public InMemoryAuthorizationSeedContributor(IRoleStore roleStore, IUserRoleStore roles, IInMemoryUserIdProvider ids, IClock clock) { + _roleStore = roleStore; _roles = roles; _ids = ids; + _clock = clock; } public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { + var adminRoleId = RoleId.From(Guid.NewGuid()); + var userRoleId = RoleId.From(Guid.NewGuid()); + var now = _clock.UtcNow; + await _roleStore.AddAsync(Role.Create(adminRoleId, tenant, "Admin", new HashSet() { Permission.Wildcard }, _clock.UtcNow)); + await _roleStore.AddAsync(Role.Create(userRoleId, tenant, "User", null, _clock.UtcNow)); + var adminKey = _ids.GetAdminUserId(); - await _roles.AssignAsync(tenant, adminKey, "Admin", ct); - await _roles.AssignAsync(tenant, adminKey, "User", ct); + await _roles.AssignAsync(tenant, adminKey, adminRoleId, now, ct); + await _roles.AssignAsync(tenant, adminKey, userRoleId, now, ct); var userKey = _ids.GetUserUserId(); - await _roles.AssignAsync(tenant, userKey, "User", ct); + await _roles.AssignAsync(tenant, userKey, userRoleId, now, ct); } } 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..81843e70 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -0,0 +1,128 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryRoleStore : InMemoryVersionedStore, IRoleStore +{ + protected override RoleKey GetKey(Role entity) => new(entity.Tenant, entity.Id); + + protected override void BeforeAdd(Role entity) + { + if (Values().Any(r => + r.Tenant == entity.Tenant && + 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 (Values().Any(r => + r.Tenant == entity.Tenant && + r.NormalizedName == entity.NormalizedName && + r.Id != entity.Id && + !r.IsDeleted)) + { + throw new UAuthConflictException("role_name_already_exists"); + } + } + } + + public Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var role = Values() + .FirstOrDefault(r => + r.Tenant == tenant && + r.NormalizedName == normalizedName && + !r.IsDeleted); + + return Task.FromResult(role); + } + + public Task> GetByIdsAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = new List(roleIds.Count); + + foreach (var id in roleIds) + { + if (TryGet(new RoleKey(tenant, id), out var role) && role is not null) + { + result.Add(role.Snapshot()); + } + } + + return Task.FromResult>(result); + } + + public Task> QueryAsync(TenantKey tenant, RoleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = Values() + .Where(r => r.Tenant == tenant); + + 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) + .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/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs index 4616d46c..64a4c958 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections.Concurrent; @@ -6,48 +8,98 @@ namespace CodeBeam.UltimateAuth.Authorization.InMemory; internal sealed class InMemoryUserRoleStore : IUserRoleStore { - private readonly ConcurrentDictionary<(TenantKey Tenant, UserKey UserKey), HashSet> _roles = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey), List> _assignments = new(); - public Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public Task> GetAssignmentsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_roles.TryGetValue((tenant, userKey), out var set)) + if (_assignments.TryGetValue((tenant, userKey), out var list)) { - lock (set) + lock (list) + return Task.FromResult>(list.ToArray()); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var list = _assignments.GetOrAdd((tenant, userKey), _ => new List()); + + lock (list) + { + if (list.Any(x => x.RoleId == roleId)) + throw new UAuthConflictException("Role is already assigned to the user."); + + list.Add(new UserRole { - return Task.FromResult>(set.ToArray()); - } + Tenant = tenant, + UserKey = userKey, + RoleId = roleId, + AssignedAt = assignedAt + }); } - return Task.FromResult>(Array.Empty()); + return Task.CompletedTask; } - public Task AssignAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default) + public Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var set = _roles.GetOrAdd((tenant, userKey), _ => new HashSet(StringComparer.OrdinalIgnoreCase)); - lock (set) + if (_assignments.TryGetValue((tenant, userKey), out var list)) { - set.Add(role); + lock (list) + { + list.RemoveAll(x => x.RoleId == roleId); + } } return Task.CompletedTask; } - public Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default) + public Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_roles.TryGetValue((tenant, userKey), out var set)) + foreach (var kv in _assignments) { - lock (set) + if (kv.Key.Item1 != tenant) + continue; + + var list = kv.Value; + + lock (list) { - set.Remove(role); + list.RemoveAll(x => x.RoleId == roleId); } } return Task.CompletedTask; } + + public Task CountAssignmentsAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var count = 0; + + foreach (var kv in _assignments) + { + if (kv.Key.Item1 != tenant) + continue; + + var list = kv.Value; + + lock (list) + { + count += list.Count(x => x.RoleId == roleId); + } + } + + return Task.FromResult(count); + } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs deleted file mode 100644 index 52d62db5..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class AssignUserRoleCommand : IAccessCommand -{ - private readonly Func _execute; - - public AssignUserRoleCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs deleted file mode 100644 index a57a1b16..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class GetUserRolesCommand : IAccessCommand> -{ - private readonly Func>> _execute; - - public GetUserRolesCommand(Func>> execute) - { - _execute = execute; - } - - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs deleted file mode 100644 index 35693190..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class RemoveUserRoleCommand : IAccessCommand -{ - private readonly Func _execute; - - public RemoveUserRoleCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs deleted file mode 100644 index 2550b229..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs +++ /dev/null @@ -1,7 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; - -public sealed class Role -{ - public required string Name { get; init; } - public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index a86fb67b..1c93e22e 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; @@ -12,17 +12,25 @@ public sealed class AuthorizationEndpointHandler : IAuthorizationEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAuthorizationService _authorization; - private readonly IUserRoleService _roles; + private readonly IUserRoleService _userRoles; + private readonly IRoleService _roles; private readonly IAccessContextFactory _accessContextFactory; - public AuthorizationEndpointHandler(IAuthFlowContextAccessor authFlow, IAuthorizationService authorization, IUserRoleService roles, 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; @@ -57,8 +65,12 @@ public async Task CheckAsync(HttpContext ctx) 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.ReadSelf, @@ -66,7 +78,7 @@ public async Task GetMyRolesAsync(HttpContext ctx) resourceId: flow.UserKey!.Value ); - var roles = await _roles.GetRolesAsync(accessContext, flow.UserKey!.Value, ctx.RequestAborted); + var roles = await _userRoles.GetRolesAsync(accessContext, flow.UserKey!.Value, req, ctx.RequestAborted); return Results.Ok(new UserRolesResponse { UserKey = flow.UserKey!.Value, @@ -81,6 +93,8 @@ public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.ReadAdmin, @@ -88,7 +102,7 @@ public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - var roles = await _roles.GetRolesAsync(accessContext, userKey, ctx.RequestAborted); + var roles = await _userRoles.GetRolesAsync(accessContext, userKey, req, ctx.RequestAborted); return Results.Ok(new UserRolesResponse { @@ -112,7 +126,7 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - await _roles.AssignAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + await _userRoles.AssignAsync(accessContext, userKey, req.Role, ctx.RequestAborted); return Results.Ok(); } @@ -131,7 +145,109 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - await _roles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + await _userRoles.RemoveAsync(accessContext, userKey, req.Role, 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 index 3142d271..49c5f58a 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs @@ -9,9 +9,10 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthAuthorizationReference(this IServiceCollection services) { services.TryAddScoped(); - 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 index 4f5d5a7b..e30a12a9 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs @@ -1,34 +1,33 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.Reference; -public sealed class RolePermissionResolver : IRolePermissionResolver +internal sealed class RolePermissionResolver : IRolePermissionResolver { - private static readonly IReadOnlyDictionary _map - = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["admin"] = new[] - { - new Permission("*") - }, - ["user"] = new[] - { - new Permission("profile.read"), - new Permission("profile.update") - } - }; - - public Task> ResolveAsync(TenantKey tenant, IEnumerable roles, CancellationToken ct = default) + private readonly IRoleStore _roles; + + public RolePermissionResolver(IRoleStore roles) { - var result = new List(); + _roles = roles; + } + + public async Task> ResolveAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) + { + if (roleIds.Count == 0) + return Array.Empty(); + + var roles = await _roles.GetByIdsAsync(tenant, roleIds, ct); + + var permissions = new HashSet(); foreach (var role in roles) { - if (_map.TryGetValue(role, out var perms)) - result.AddRange(perms); + foreach (var perm in role.Permissions) + permissions.Add(perm); } - return Task.FromResult>(result); + 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 index 150eae91..b7ab7070 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs @@ -1,23 +1,24 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.Reference; -public sealed class UserPermissionStore : IUserPermissionStore +internal sealed class UserPermissionStore : IUserPermissionStore { - private readonly IUserRoleStore _roles; + private readonly IUserRoleStore _userRoles; private readonly IRolePermissionResolver _resolver; - public UserPermissionStore(IUserRoleStore roles, IRolePermissionResolver resolver) + public UserPermissionStore(IUserRoleStore userRoles, IRolePermissionResolver resolver) { - _roles = roles; + _userRoles = userRoles; _resolver = resolver; } public async Task> GetPermissionsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var roles = await _roles.GetRolesAsync(tenant, userKey, ct); - return await _resolver.ResolveAsync(tenant, roles, ct); + var assignments = await _userRoles.GetAssignmentsAsync(tenant, 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/Services/AuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs index 3b01d601..720bd196 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs @@ -1,36 +1,36 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Policies.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class AuthorizationService : IAuthorizationService { - private readonly IAccessPolicyProvider _policyProvider; - private readonly IAccessAuthority _accessAuthority; + private readonly IAccessOrchestrator _accessOrchestrator; - public AuthorizationService(IAccessPolicyProvider policyProvider, IAccessAuthority accessAuthority) + public AuthorizationService(IAccessOrchestrator accessOrchestrator) { - _policyProvider = policyProvider; - _accessAuthority = accessAuthority; + _accessOrchestrator = accessOrchestrator; } - public Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) + public async Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var policies = _policyProvider.GetPolicies(context); - var decision = _accessAuthority.Decide(context, policies); - - if (decision.RequiresReauthentication) - return Task.FromResult(AuthorizationResult.ReauthRequired()); - - return Task.FromResult( - decision.IsAllowed - ? AuthorizationResult.Allow() - : AuthorizationResult.Deny(decision.DenyReason) - ); + 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/RoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs new file mode 100644 index 00000000..c5746fad --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs @@ -0,0 +1,126 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +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 IRoleStore _roles; + private readonly IUserRoleStore _userRoles; + private readonly IClock _clock; + + public RoleService( + IAccessOrchestrator accessOrchestrator, + IRoleStore roles, + IUserRoleStore userRoles, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _roles = roles; + _userRoles = userRoles; + _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); + await _roles.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 role = await _roles.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 _roles.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 role = await _roles.GetAsync(key, innerCt); + + if (role is null) + throw new UAuthNotFoundException("role_not_found"); + + var removed = await _userRoles.CountAssignmentsAsync(context.ResourceTenant, roleId, innerCt); + await _userRoles.RemoveAssignmentsByRoleAsync(context.ResourceTenant, roleId, innerCt); + await _roles.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 key = new RoleKey(context.ResourceTenant, roleId); + var role = await _roles.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 _roles.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 => + { + return await _roles.QueryAsync(context.ResourceTenant, 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 index 1def14bf..5d987173 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -7,52 +9,94 @@ namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class UserRoleService : IUserRoleService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserRoleStore _store; + private readonly IUserRoleStore _userRoles; + private readonly IRoleStore _roles; + private readonly IClock _clock; - public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore store) + public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore userRoles, IRoleStore roles, IClock clock) { _accessOrchestrator = accessOrchestrator; - _store = store; + _userRoles = userRoles; + _roles = roles; + _clock = clock; } - public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(role)) - throw new ArgumentException("role_empty", nameof(role)); + var now = _clock.UtcNow; - var cmd = new AssignUserRoleCommand( - async innerCt => - { - await _store.AssignAsync(context.ResourceTenant, targetUserKey, role, innerCt); - }); + var cmd = new AccessCommand(async innerCt => + { + var normalized = roleName.Trim().ToUpperInvariant(); + var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); + + if (role is null || role.IsDeleted) + throw new InvalidOperationException("role_not_found"); + + await _userRoles.AssignAsync(context.ResourceTenant, targetUserKey, role.Id, now, innerCt); + }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(role)) - throw new ArgumentException("role_empty", nameof(role)); + var cmd = new AccessCommand(async innerCt => + { + var normalized = roleName.Trim().ToUpperInvariant(); + var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); + + if (role is null) + return; - var cmd = new RemoveUserRoleCommand( - async innerCt => - { - await _store.RemoveAsync(context.ResourceTenant, targetUserKey, role, innerCt); - }); + await _userRoles.RemoveAsync(context.ResourceTenant, targetUserKey, role.Id, innerCt); + }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - - public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) + public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, PageRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new GetUserRolesCommand(innerCt => _store.GetRolesAsync(context.ResourceTenant, targetUserKey, innerCt)); + var cmd = new AccessCommand>(async innerCt => + { + request = request.Normalize(); + + var assignments = await _userRoles.GetAssignmentsAsync(context.ResourceTenant, targetUserKey, innerCt); + var roleIds = assignments.Select(x => x.RoleId).ToArray(); + var roles = await _roles.GetByIdsAsync(context.ResourceTenant, 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/Abstractions/IRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs index d6e09167..119d5002 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IRolePermissionResolver { - Task> ResolveAsync(TenantKey tenant, IEnumerable roles, CancellationToken ct = default); + 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..bc794624 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +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..5b263885 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRoleStore : IVersionedStore +{ + Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default); + Task> GetByIdsAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default); + Task> QueryAsync(TenantKey tenant, RoleQuery query, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs index db519dc2..eb2aabaa 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs index 3c9a4f29..ba96b03d 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs @@ -1,11 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +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 role, CancellationToken ct = default); - Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); - Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); + 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 index 028f5f6a..a87fc1e3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs @@ -1,11 +1,14 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IUserRoleStore { - Task AssignAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default); - Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default); - Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task> GetAssignmentsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default); + Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default); + Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default); + Task CountAssignmentsAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj index ce41f1eb..d1493e50 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -12,6 +12,7 @@ + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs deleted file mode 100644 index 69dc0f07..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Domain; - -public readonly record struct Permission(string Value) -{ - public override string ToString() => Value; -} 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..b008663c --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -0,0 +1,135 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class Role : 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 InvalidOperationException("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 InvalidOperationException("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; + } + + 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..f52f8cfb --- /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.Domain; + +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/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs similarity index 55% rename from src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs rename to src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs index eb58589c..cc427cdd 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Security.Claims; @@ -8,28 +9,32 @@ namespace CodeBeam.UltimateAuth.Authorization; public sealed class AuthorizationClaimsProvider : IUserClaimsProvider { private readonly IUserRoleStore _roles; + private readonly IRoleStore _roleStore; private readonly IUserPermissionStore _permissions; - public AuthorizationClaimsProvider(IUserRoleStore roles, IUserPermissionStore permissions) + public AuthorizationClaimsProvider(IUserRoleStore roles, IRoleStore roleStore, IUserPermissionStore permissions) { _roles = roles; + _roleStore = roleStore; _permissions = permissions; } public async Task GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var roles = await _roles.GetRolesAsync(tenant, userKey, ct); + var assignments = await _roles.GetAssignmentsAsync(tenant, userKey, ct); + var roleIds = assignments.Select(x => x.RoleId).Distinct().ToArray(); + var roles = await _roleStore.GetByIdsAsync(tenant, roleIds, ct); var perms = await _permissions.GetPermissionsAsync(tenant, userKey, ct); var builder = ClaimsSnapshot.Create(); - builder.Add("uauth:tenant", tenant.Value); + builder.Add(UAuthConstants.Claims.Tenant, tenant.Value); foreach (var role in roles) - builder.Add(ClaimTypes.Role, role); + builder.Add(ClaimTypes.Role, role.Name); foreach (var perm in perms) - builder.Add("uauth:permission", perm.Value); + builder.Add(UAuthConstants.Claims.Permission, perm.Value); return builder.Build(); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs deleted file mode 100644 index 2a6848a4..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs +++ /dev/null @@ -1,29 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Authorization; - -public sealed class PermissionAccessPolicy : IAccessPolicy -{ - private readonly IReadOnlySet _permissions; - private readonly string _operation; - - public PermissionAccessPolicy(IEnumerable permissions, string operation) - { - _permissions = permissions.Select(p => p.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); - _operation = operation; - } - - public bool AppliesTo(AccessContext context) => context.ActorUserKey is not null; - - public AccessDecision Decide(AccessContext context) - { - if (context.ActorUserKey is null) - return AccessDecision.Deny("unauthenticated"); - - return _permissions.Contains(_operation) - ? AccessDecision.Allow() - : AccessDecision.Deny("missing_permission"); - } -} 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/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs index e236ba27..2c3df24a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -1,7 +1,10 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CredentialDto { + public Guid Id { get; set; } public CredentialType Type { get; init; } public CredentialSecurityStatus Status { get; init; } @@ -10,12 +13,11 @@ public sealed record CredentialDto public DateTimeOffset? LastUsedAt { get; init; } - public DateTimeOffset? LockedUntil { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } public DateTimeOffset? RevokedAt { get; init; } - public DateTimeOffset? ResetRequestedAt { get; init; } public string? Source { get; init; } + + public long Version { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index 2291404c..27436d4a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -1,44 +1,37 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed class CredentialSecurityState { public DateTimeOffset? RevokedAt { get; } - public DateTimeOffset? LockedUntil { get; } public DateTimeOffset? ExpiresAt { get; } - public DateTimeOffset? ResetRequestedAt { get; init; } public Guid SecurityStamp { get; } - public CredentialSecurityStatus Status(DateTimeOffset now) - { - if (RevokedAt is not null) - return CredentialSecurityStatus.Revoked; - - if (LockedUntil is not null && LockedUntil > now) - return CredentialSecurityStatus.Locked; - - if (ExpiresAt is not null && ExpiresAt <= now) - return CredentialSecurityStatus.Expired; - - if (ResetRequestedAt is not null) - return CredentialSecurityStatus.ResetRequested; - - return CredentialSecurityStatus.Active; - } + public bool IsRevoked => RevokedAt != null; + public bool IsExpired => ExpiresAt != null; public CredentialSecurityState( DateTimeOffset? revokedAt = null, - DateTimeOffset? lockedUntil = null, DateTimeOffset? expiresAt = null, - DateTimeOffset? resetRequestedAt = null, Guid securityStamp = default) { RevokedAt = revokedAt; - LockedUntil = lockedUntil; ExpiresAt = expiresAt; - ResetRequestedAt = resetRequestedAt; SecurityStamp = securityStamp; } + public CredentialSecurityStatus Status(DateTimeOffset now) + { + if (RevokedAt is not null) + return CredentialSecurityStatus.Revoked; + + if (ExpiresAt is not null && ExpiresAt <= now) + return CredentialSecurityStatus.Expired; + + return CredentialSecurityStatus.Active; + } + /// /// Determines whether the credential can be used at the given time. /// @@ -48,57 +41,50 @@ public static CredentialSecurityState Active(Guid? securityStamp = null) { return new CredentialSecurityState( revokedAt: null, - lockedUntil: null, expiresAt: null, - resetRequestedAt: null, - securityStamp: securityStamp ?? Guid.NewGuid()); + 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, - lockedUntil: LockedUntil, expiresAt: ExpiresAt, - resetRequestedAt: ResetRequestedAt, - securityStamp: Guid.NewGuid()); + 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, - lockedUntil: LockedUntil, expiresAt: expiresAt, - resetRequestedAt: ResetRequestedAt, - securityStamp: SecurityStamp); + securityStamp: EnsureStamp(SecurityStamp) + ); } - public CredentialSecurityState BeginReset(DateTimeOffset now, bool rotateStamp = true) - => new( - revokedAt: RevokedAt, - lockedUntil: LockedUntil, - expiresAt: ExpiresAt, - resetRequestedAt: now, - securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp - ); - - public CredentialSecurityState CompleteReset(bool rotateStamp = true) - => new( - revokedAt: RevokedAt, - lockedUntil: LockedUntil, - expiresAt: ExpiresAt, - resetRequestedAt: null, - securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp - ); + private static Guid EnsureStamp(Guid stamp) => stamp == Guid.Empty ? Guid.NewGuid() : stamp; public CredentialSecurityState RotateStamp() { return new CredentialSecurityState( revokedAt: RevokedAt, - lockedUntil: LockedUntil, expiresAt: ExpiresAt, - resetRequestedAt: ResetRequestedAt, - securityStamp: Guid.NewGuid()); + securityStamp: Guid.NewGuid() + ); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs index d90804d7..d75b3543 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public static class CredentialTypeParser { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs index 89a903c5..2f6b46dd 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialRequest() { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs index 98eb2e4c..dd96bf98 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs @@ -1,6 +1,12 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record BeginCredentialResetRequest { - public string? Reason { get; init; } + public string Identifier { get; init; } = default!; + public CredentialType CredentialType { get; set; } = CredentialType.Password; + public ResetCodeType ResetCodeType { get; set; } + 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 index 85c5770e..56928ebc 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -2,8 +2,7 @@ public sealed record ChangeCredentialRequest { - public CredentialType Type { get; init; } - - public string CurrentSecret { get; init; } = default!; - public string NewSecret { get; init; } = default!; + 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/CompleteCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs index 7dcaf3da..ec7abac5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs @@ -1,7 +1,11 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CompleteCredentialResetRequest { + public string? Identifier { get; init; } + public CredentialType CredentialType { get; set; } = CredentialType.Password; + public string? ResetToken { get; init; } public required string NewSecret { get; init; } - public string? Source { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs new file mode 100644 index 00000000..d0f3465f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialActionRequest +{ + public Guid Id { get; set; } + public string? Reason { get; set; } +} 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..b1bf31fa --- /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 class DeleteCredentialRequest +{ + public Guid Id { get; init; } + public DeleteMode Mode { get; set; } = DeleteMode.Soft; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs index a895d5e7..0f185532 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs @@ -1,10 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ResetPasswordRequest { - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; set; } public required string NewPassword { get; init; } /// diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs index 108fa25c..b6a64cd6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs @@ -2,6 +2,8 @@ public sealed record RevokeCredentialRequest { + public Guid Id { get; init; } + /// /// If specified, credential is revoked until this time. /// Null means permanent revocation. diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs index 2ccd937b..a40a8345 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record SetInitialCredentialRequest { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs index bd530568..0268a697 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ValidateCredentialsRequest { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs index dd9f3daa..5a226706 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialResult { @@ -6,19 +8,24 @@ public sealed record AddCredentialResult public string? Error { get; init; } + public Guid? Id { get; set; } public CredentialType? Type { get; init; } - public static AddCredentialResult Success(CredentialType type) + public static AddCredentialResult Success(Guid id, CredentialType type) => new() { Succeeded = true, - Type = type + Id = id, + Type = type, + Error = null }; public static AddCredentialResult Fail(string error) => new() { Succeeded = false, - Error = error + 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 index 8579bc23..6a78c338 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs @@ -1,8 +1,10 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ChangeCredentialResult { - public bool Succeeded { get; init; } + public bool IsSuccess { get; init; } public string? Error { get; init; } @@ -11,14 +13,14 @@ public sealed record ChangeCredentialResult public static ChangeCredentialResult Success(CredentialType type) => new() { - Succeeded = true, + IsSuccess = true, Type = type }; public static ChangeCredentialResult Fail(string error) => new() { - Succeeded = false, + IsSuccess = false, Error = error }; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs index 36d9ae8d..c116b8e6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CredentialProvisionResult { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index 2598739f..e4ef841f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; @@ -9,40 +10,47 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory; internal sealed class InMemoryCredentialSeedContributor : ISeedContributor { + private static readonly Guid _adminPasswordId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _userPasswordId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); public int Order => 10; private readonly ICredentialStore _credentials; private readonly IInMemoryUserIdProvider _ids; private readonly IUAuthPasswordHasher _hasher; + private readonly IClock _clock; - public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) + public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) { _credentials = credentials; _ids = ids; _hasher = hasher; + _clock = clock; } public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { - await SeedCredentialAsync(_ids.GetAdminUserId(), "admin", tenant, ct); - await SeedCredentialAsync(_ids.GetUserUserId(), "user", tenant, ct); + await SeedCredentialAsync(_ids.GetAdminUserId(), _adminPasswordId, "admin", tenant, ct); + await SeedCredentialAsync(_ids.GetUserUserId(), _userPasswordId, "user", tenant, ct); } - private async Task SeedCredentialAsync(UserKey userKey, string hash, TenantKey tenant, CancellationToken ct) + private async Task SeedCredentialAsync(UserKey userKey, Guid credentialId, string secretHash, TenantKey tenant, CancellationToken ct) { - if (await _credentials.ExistsAsync(tenant, userKey, CredentialType.Password, null, ct)) - return; - - await _credentials.AddAsync(tenant, - new PasswordCredential( - Guid.NewGuid(), - tenant, - userKey, - _hasher.Hash(hash), - CredentialSecurityState.Active(), - new CredentialMetadata(), - DateTimeOffset.UtcNow, - null), - ct); + try + { + await _credentials.AddAsync( + PasswordCredential.Create( + credentialId, + tenant, + userKey, + _hasher.Hash(secretHash), + CredentialSecurityState.Active(), + new CredentialMetadata(), + _clock.UtcNow), + ct); + } + catch (UAuthConflictException) + { + // already seeded + } } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 263c365b..65d6f39c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -1,177 +1,87 @@ 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; -using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Credentials.InMemory; -internal sealed class InMemoryCredentialStore : ICredentialStore +internal sealed class InMemoryCredentialStore : InMemoryVersionedStore, ICredentialStore { - private readonly ConcurrentDictionary<(TenantKey Tenant, Guid Id), PasswordCredential> _byId = new(); - private readonly ConcurrentDictionary<(TenantKey Tenant, UserKey UserKey), ConcurrentDictionary> _byUser = new(); - - private readonly IUAuthPasswordHasher _hasher; - - public InMemoryCredentialStore(IUAuthPasswordHasher hasher) - { - _hasher = hasher; - } + protected override CredentialKey GetKey(PasswordCredential entity) => new(entity.Tenant, entity.Id); public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenant, userKey), out var ids) || ids.Count == 0) - return Task.FromResult>(Array.Empty()); - - var list = new List(ids.Count); - - foreach (var id in ids.Keys) - { - if (_byId.TryGetValue((tenant, id), out var cred)) - { - list.Add(cred); - } - } + var result = Values() + .Where(c => c.Tenant == tenant && c.UserKey == userKey) + .Cast() + .ToArray(); - return Task.FromResult>(list); + return Task.FromResult>(result); } - public Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default) + public Task GetByIdAsync(CredentialKey key, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - _byId.TryGetValue((tenant, credentialId), out var cred); - return Task.FromResult(cred); - } - - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CredentialType type, string? secretHash, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + if (TryGet(key, out var entity)) + return Task.FromResult(entity); - if (!_byUser.TryGetValue((tenant, userKey), out var ids) || ids.Count == 0) - return Task.FromResult(false); - - foreach (var id in ids.Keys) - { - if (!_byId.TryGetValue((tenant, id), out var cred)) - continue; - - if (cred.Type != type) - continue; - - if (secretHash is null) - return Task.FromResult(true); - - if (string.Equals(cred.SecretHash, secretHash, StringComparison.Ordinal)) - return Task.FromResult(true); - } - - return Task.FromResult(false); + return Task.FromResult(entity); } - public Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) + public Task AddAsync(ICredential credential, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - // TODO: Support other credential types if needed. For now, we only have PasswordCredential in-memory. + // TODO: Implement other credential types if (credential is not PasswordCredential pwd) throw new NotSupportedException("Only password credentials are supported in-memory."); - var id = pwd.Id == Guid.Empty ? Guid.NewGuid() : pwd.Id; - - var key = (tenant, id); - if (_byId.ContainsKey(key)) - throw new InvalidOperationException("credential_already_exists"); - - if (pwd.Id == Guid.Empty) - throw new InvalidOperationException("credential_id_required"); - - if (!_byId.TryAdd(key, pwd)) - throw new InvalidOperationException("credential_already_exists"); - - var userIndex = _byUser.GetOrAdd((tenant, pwd.UserKey), _ => new ConcurrentDictionary()); - userIndex.TryAdd(pwd.Id, 0); - - return Task.CompletedTask; + return base.AddAsync(pwd, ct); } - public Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) + public Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - if (credential is not PasswordCredential pwd) throw new NotSupportedException("Only password credentials are supported in-memory."); - var key = (tenant, pwd.Id); - - if (!_byId.ContainsKey(key)) - throw new InvalidOperationException("credential_not_found"); - - _byId[key] = pwd; - - return Task.CompletedTask; + return base.SaveAsync(pwd, expectedVersion, ct); } - public Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var key = (tenant, credentialId); - - if (!_byId.TryGetValue(key, out var cred)) - throw new InvalidOperationException("credential_not_found"); + if (!TryGet(key, out var credential)) + throw new UAuthNotFoundException("credential_not_found"); - if (cred.IsRevoked) - return Task.CompletedTask; + if (credential is not PasswordCredential pwd) + throw new NotSupportedException("Only password credentials are supported in-memory."); - cred.Revoke(revokedAt); + var revoked = pwd.Revoke(revokedAt); - return Task.CompletedTask; + return SaveAsync(revoked, expectedVersion, ct); } - public Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - var key = (tenant, credentialId); - - if (!_byId.TryGetValue(key, out var cred)) - return Task.CompletedTask; - - if (mode == DeleteMode.Hard) - { - _byId.TryRemove(key, out _); - - if (_byUser.TryGetValue((tenant, cred.UserKey), out var set)) - { - set.TryRemove(credentialId, out _); - } - - return Task.CompletedTask; - } - - if (!cred.IsRevoked) - cred.Revoke(now); - - return Task.CompletedTask; + return base.DeleteAsync(key, expectedVersion, mode, now, ct); } - public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenant, userKey), out var ids)) - return Task.CompletedTask; + var credentials = Values() + .Where(c => c.Tenant == tenant && c.UserKey == userKey) + .ToList(); - foreach (var id in ids.Keys.ToList()) + foreach (var credential in credentials) { - DeleteAsync(tenant, id, mode, now, ct); + await DeleteAsync(new CredentialKey(tenant, credential.Id), mode, now, credential.Version, ct); } - - return Task.CompletedTask; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs deleted file mode 100644 index d496e7f7..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class ActivateCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public ActivateCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs deleted file mode 100644 index e0426f87..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class AddCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public AddCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs deleted file mode 100644 index 7cd3bde0..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class BeginCredentialResetCommand : IAccessCommand -{ - private readonly Func> _execute; - - public BeginCredentialResetCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs deleted file mode 100644 index f9117ea8..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class ChangeCredentialCommand: IAccessCommand -{ - private readonly Func> _execute; - - public ChangeCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs deleted file mode 100644 index 7bf3a0f6..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class CompleteCredentialResetCommand : IAccessCommand -{ - private readonly Func> _execute; - - public CompleteCredentialResetCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs deleted file mode 100644 index ae290b3d..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class DeleteCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public DeleteCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs deleted file mode 100644 index 098d01bc..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class GetAllCredentialsCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetAllCredentialsCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs deleted file mode 100644 index 70bf7851..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class RevokeCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public RevokeCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs deleted file mode 100644 index 4efed9f9..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class SetInitialCredentialCommand : IAccessCommand -{ - private readonly Func _execute; - - public SetInitialCredentialCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 702eb1d5..9650be6c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -1,78 +1,153 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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, ICredentialDescriptor +public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor, IVersionedEntity, IEntitySnapshot, ISoftDeletable { public Guid Id { get; init; } public TenantKey Tenant { get; init; } public UserKey UserKey { get; init; } public CredentialType Type => CredentialType.Password; - public string SecretHash { get; private set; } - - public CredentialSecurityState Security { get; private set; } - public CredentialMetadata Metadata { get; private set; } + 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; - public PasswordCredential( - Guid? id, + private PasswordCredential() { } + + private PasswordCredential( + Guid id, TenantKey tenant, UserKey userKey, string secretHash, CredentialSecurityState security, CredentialMetadata metadata, DateTimeOffset createdAt, - DateTimeOffset? updatedAt) + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) { - Id = id ?? Guid.NewGuid(); + 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; - Metadata = metadata; + 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 void ChangeSecret(string newSecretHash, DateTimeOffset now) + 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 ArgumentException("Secret hash cannot be empty.", nameof(newSecretHash)); + throw new UAuthValidationException("credential_secret_required"); if (IsRevoked) - throw new InvalidOperationException("Cannot change secret of a revoked credential."); + 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; - UpdatedAt = now; Security = Security.RotateStamp(); + UpdatedAt = now; + + return this; } - public void SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) + public PasswordCredential SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) { + if (IsExpired(now)) + return this; + Security = Security.SetExpiry(expiresAt); UpdatedAt = now; + + return this; } - public void UpdateSecurity(CredentialSecurityState security, DateTimeOffset now) + public PasswordCredential Revoke(DateTimeOffset now) { - Security = security; + if (IsRevoked) + return this; + + Security = Security.Revoke(now); UpdatedAt = now; + + return this; } - public void Revoke(DateTimeOffset now) + public PasswordCredential MarkDeleted(DateTimeOffset now) { - if (IsRevoked) - return; - Security = Security.Revoke(now); + if (IsDeleted) + return this; + + DeletedAt = now; UpdatedAt = now; + + return this; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index 10b8092f..c95daf58 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -1,7 +1,7 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; @@ -12,9 +12,9 @@ public sealed class CredentialEndpointHandler : ICredentialEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserCredentialsService _credentials; + private readonly ICredentialManagementService _credentials; - public CredentialEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserCredentialsService credentials) + public CredentialEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, ICredentialManagementService credentials) { _authFlow = authFlow; _accessContextFactory = accessContextFactory; @@ -53,14 +53,11 @@ public async Task AddAsync(HttpContext ctx) return Results.Ok(result); } - public async Task ChangeAsync(string type, HttpContext ctx) + public async Task ChangeSecretAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -69,20 +66,15 @@ public async Task ChangeAsync(string type, HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - var result = await _credentials.ChangeAsync( - accessContext, credentialType, request, ctx.RequestAborted); - + var result = await _credentials.ChangeSecretAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(result); } - public async Task RevokeAsync(string type, HttpContext ctx) + public async Task RevokeAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -91,48 +83,42 @@ public async Task RevokeAsync(string type, HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task BeginResetAsync(string type, HttpContext ctx) + public async Task BeginResetAsync(HttpContext ctx) { - if (!TryGetSelf(out var flow, out var error)) - return error!; - - if (!TryParseType(type, out var credentialType, out error)) - return error!; + // 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.BeginResetSelf, + action: UAuthActions.Credentials.BeginResetAnonymous, resource: "credentials", - resourceId: flow.UserKey!.Value); + resourceId: request.Identifier); - await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); } - public async Task CompleteResetAsync(string type, HttpContext ctx) + public async Task CompleteResetAsync(HttpContext ctx) { - if (!TryGetSelf(out var flow, out var error)) - return error!; - - if (!TryParseType(type, out var credentialType, out error)) - return error!; + // 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.CompleteResetSelf, + action: UAuthActions.Credentials.CompleteResetAnonymous, resource: "credentials", - resourceId: flow.UserKey!.Value); + resourceId: request.Identifier); - await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); } public async Task GetAllAdminAsync(UserKey userKey, HttpContext ctx) @@ -170,56 +156,49 @@ public async Task AddAdminAsync(UserKey userKey, HttpContext ctx) return Results.Ok(result); } - public async Task RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task ChangeSecretAdminAsync(UserKey userKey, HttpContext ctx) { - var flow = _authFlow.Current; - if (!flow.IsAuthenticated) - return Results.Unauthorized(); - - if (!TryParseType(type, out var credentialType, out var error)) + if (!TryGetSelf(out var flow, out var error)) return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Credentials.RevokeAdmin, + action: UAuthActions.Credentials.ChangeAdmin, resource: "credentials", resourceId: userKey.Value); - await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); - - return Results.NoContent(); + var result = await _credentials.ChangeSecretAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); } - public async Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; if (!flow.IsAuthenticated) return Results.Unauthorized(); - if (!TryParseType(type, out var credentialType, out var error)) - return error!; + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Credentials.ActivateAdmin, + action: UAuthActions.Credentials.RevokeAdmin, resource: "credentials", resourceId: userKey.Value); - await _credentials.ActivateAsync(accessContext, credentialType, ctx.RequestAborted); + await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task DeleteAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; if (!flow.IsAuthenticated) return Results.Unauthorized(); - if (!TryParseType(type, out var credentialType, out var error)) - return error!; + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -227,19 +206,16 @@ public async Task DeleteAdminAsync(UserKey userKey, string type, HttpCo resource: "credentials", resourceId: userKey.Value); - await _credentials.DeleteAsync(accessContext, credentialType, ctx.RequestAborted); + await _credentials.DeleteAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -248,18 +224,15 @@ public async Task BeginResetAdminAsync(UserKey userKey, string type, Ht resource: "credentials", resourceId: userKey.Value); - await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -268,7 +241,7 @@ public async Task CompleteResetAdminAsync(UserKey userKey, string type, resource: "credentials", resourceId: userKey.Value); - await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } @@ -284,16 +257,4 @@ private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) error = null; return true; } - - private static bool TryParseType(string type, out CredentialType credentialType, out IResult? error) - { - if (!CredentialTypeParser.TryParse(type, out credentialType)) - { - error = Results.BadRequest($"Unsupported credential type: {type}"); - 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 index 731fb046..12497321 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -10,8 +10,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs deleted file mode 100644 index f356db6c..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal static class PasswordCredentialFactory -{ - public static PasswordCredential Create(TenantKey tenant, UserKey userKey, string secretHash, string? source, DateTimeOffset now) - => new PasswordCredential( - id: Guid.NewGuid(), - tenant: tenant, - userKey: userKey, - secretHash: secretHash, - security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata { Source = source }, - createdAt: now, - updatedAt: null); -} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index e1d17fcc..efd94263 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -31,17 +31,16 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r var hash = _passwordHasher.Hash(r.Password); - var credential = new PasswordCredential( + var credential = PasswordCredential.Create( id: null, tenant: tenant, userKey: userKey, secretHash: hash, security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata { LastUsedAt = _clock.UtcNow }, - _clock.UtcNow, - null); + metadata: new CredentialMetadata { }, + _clock.UtcNow); - await _credentialStore.AddAsync(tenant, credential, ct); + await _credentialStore.AddAsync(credential, ct); } public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) 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..568a6788 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -0,0 +1,382 @@ +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.AspNetCore.Session; +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 ICredentialStore _credentials; + 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, + ICredentialStore credentials, + IAuthenticationSecurityManager authenticationSecurityManager, + IOpaqueTokenGenerator tokenGenerator, + INumericCodeGenerator numericCodeGenerator, + IUAuthPasswordHasher hasher, + ITokenHasher tokenHasher, + ILoginIdentifierResolver identifierResolver, + ISessionStoreFactory sessionFactory, + IOptions options, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _credentials = credentials; + _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 credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); + + var dtos = credentials + .OfType() + .Select(c => new CredentialDto + { + 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); + + await _credentials.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 credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); + var pwd = credentials.OfType().Where(c => c.Security.IsUsable(now)).SingleOrDefault(); + + 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 _credentials.SaveAsync(updated, oldVersion, innerCt); + + var sessionStore = _sessionFactory.Create(context.ResourceTenant); + if (context.IsSelfAction && context.ActorChainId is SessionChainId chainId) + { + await sessionStore.RevokeOtherChainsAsync(context.ResourceTenant, subjectUser, chainId, now, innerCt); + } + else + { + await sessionStore.RevokeAllChainsAsync(context.ResourceTenant, 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 credential = await _credentials.GetByIdAsync(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 _credentials.SaveAsync(updated, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest 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, CompleteCredentialResetRequest 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 credentials = await _credentials.GetByUserAsync(context.ResourceTenant, 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 _credentials.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 credential = await _credentials.GetByIdAsync(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 _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), request.Mode, now, oldVersion, 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(); + + await _credentials.DeleteByUserAsync(tenant, 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..549659bf --- /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, BeginCredentialResetRequest request, CancellationToken ct = default); + + Task CompleteResetAsync(AccessContext context, CompleteCredentialResetRequest request, CancellationToken ct = default); + + Task DeleteAsync(AccessContext context, DeleteCredentialRequest request, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs deleted file mode 100644 index eaddd57a..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -public interface IUserCredentialsService -{ - Task GetAllAsync(AccessContext context, CancellationToken ct = default); - - Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); - - Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default); - - Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default); - - Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default); - - Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct = default); - - Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct = default); - - Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs deleted file mode 100644 index d74955b5..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs +++ /dev/null @@ -1,284 +0,0 @@ -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.Credentials.Reference.Internal; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class UserCredentialsService : IUserCredentialsService, IUserCredentialsInternalService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly ICredentialStore _credentials; - private readonly IUAuthPasswordHasher _hasher; - private readonly IClock _clock; - - public UserCredentialsService( - IAccessOrchestrator accessOrchestrator, - ICredentialStore credentials, - IUAuthPasswordHasher hasher, - IClock clock) - { - _accessOrchestrator = accessOrchestrator; - _credentials = credentials; - _hasher = hasher; - _clock = clock; - } - - public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new GetAllCredentialsCommand( - async innerCt => - { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); - - var creds = await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt); - - var dtos = creds - .OfType() - .Select(c => new CredentialDto { - Type = c.Type, - Status = c.Security.Status(_clock.UtcNow), - LastUsedAt = c.Metadata.LastUsedAt, - LockedUntil = c.Security.LockedUntil, - ExpiresAt = c.Security.ExpiresAt, - RevokedAt = c.Security.RevokedAt, - ResetRequestedAt = c.Security.ResetRequestedAt, - Source = c.Metadata.Source}) - .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 AddCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var alreadyHasType = (await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt)) - .OfType() - .Any(c => c.Type == request.Type); - - if (alreadyHasType) - return AddCredentialResult.Fail("credential_already_exists"); - - var hash = _hasher.Hash(request.Secret); - - var credential = PasswordCredentialFactory.Create( - tenant: context.ResourceTenant, - userKey: userKey, - secretHash: hash, - source: request.Source, - now: now); - - await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); - - return AddCredentialResult.Success(request.Type); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - - public async Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new ChangeCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return ChangeCredentialResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - var hash = _hasher.Hash(request.NewSecret); - pwd.ChangeSecret(hash, now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - } - else - { - return ChangeCredentialResult.Fail("credential_type_unsupported"); - } - - return ChangeCredentialResult.Success(type); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - - // ---------------- REVOKE ---------------- - - public async Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new RevokeCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - await _credentials.RevokeAsync(context.ResourceTenant, GetId(cred), now, innerCt); - return CredentialActionResult.Success(); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new ActivateCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is ICredentialDescriptor desc && cred is PasswordCredential pwd) - { - pwd.UpdateSecurity(CredentialSecurityState.Active(pwd.Security.SecurityStamp), now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct) - { - var cmd = new BeginCredentialResetCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - pwd.UpdateSecurity(pwd.Security.BeginReset(now), now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct) - { - var cmd = new CompleteCredentialResetCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - var hash = _hasher.Hash(request.NewSecret); - pwd.ChangeSecret(hash, now); - pwd.UpdateSecurity(pwd.Security.CompleteReset(), now); - - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new DeleteCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - await _credentials.DeleteAsync( - tenant: context.ResourceTenant, - credentialId: GetId(cred), - mode: DeleteMode.Soft, - now: now, - ct: 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(); - - await _credentials.DeleteByUserAsync(tenant, userKey, DeleteMode.Soft, _clock.UtcNow, ct); - return CredentialActionResult.Success(); - } - - - private static UserKey EnsureActor(AccessContext context) - => context.ActorUserKey is UserKey uk ? uk : throw new UnauthorizedAccessException(); - - private static Guid GetId(ICredential c) - => c switch - { - PasswordCredential p => p.Id, - _ => throw new NotSupportedException("credential_id_missing") - }; - - private async Task GetSingleByTypeAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct) - { - var creds = await _credentials.GetByUserAsync(tenant, userKey, ct); - - var found = creds.OfType().FirstOrDefault(x => x.Type == type); - - return found as ICredential; - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs index f2f9879b..a9cdc74b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -1,10 +1,12 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Credentials; public interface ICredential { + Guid Id { get; } + TenantKey Tenant { get; init; } UserKey UserKey { get; init; } CredentialType Type { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs index c8a4af55..bc513d3a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs @@ -1,10 +1,13 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials; public interface ICredentialDescriptor { + Guid Id { get; } CredentialType Type { get; } CredentialSecurityState Security { get; } CredentialMetadata Metadata { get; } + long Version { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs index e55c42b6..1e2f3b86 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -8,11 +8,10 @@ namespace CodeBeam.UltimateAuth.Credentials; public interface ICredentialStore { Task>GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default); - Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); - Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); - Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, CancellationToken ct = default); - Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); + Task GetByIdAsync(CredentialKey key, CancellationToken ct = default); + Task AddAsync(ICredential credential, CancellationToken ct = default); + Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default); + Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); + Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); - Task ExistsAsync(TenantKey tenant, UserKey userKey, CredentialType type, string? secretHash, CancellationToken ct = default); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj index ce41f1eb..6db7b9d7 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -11,6 +11,7 @@ + diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs index f63214b0..c7ccf270 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -15,8 +16,10 @@ public static void Register(AccessPolicyRegistry registry) // Intent-based registry.Add("", _ => new RequireSelfPolicy()); - registry.Add("", _ => new RequireAdminPolicy()); - registry.Add("", _ => new RequireSelfOrAdminPolicy()); + registry.Add("", _ => new DenyAdminSelfModificationPolicy()); registry.Add("", _ => new RequireSystemPolicy()); + + // Permission + registry.Add("", _ => new MustHavePermissionPolicy()); } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs index 594f2723..4c6181ae 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -29,8 +30,7 @@ private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy } public IPolicyScopeBuilder RequireSelf() => Add(); - public IPolicyScopeBuilder RequireAdmin() => Add(); - public IPolicyScopeBuilder RequireSelfOrAdmin() => Add(); + public IPolicyScopeBuilder RequirePermission() => Add(); public IPolicyScopeBuilder RequireAuthenticated() => Add(); public IPolicyScopeBuilder DenyCrossTenant() => Add(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs index 1916eeee..9f400fae 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs @@ -4,7 +4,6 @@ public interface IPolicyScopeBuilder { IPolicyScopeBuilder RequireAuthenticated(); IPolicyScopeBuilder RequireSelf(); - IPolicyScopeBuilder RequireAdmin(); - IPolicyScopeBuilder RequireSelfOrAdmin(); + IPolicyScopeBuilder RequirePermission(); IPolicyScopeBuilder DenyCrossTenant(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs index 61adab98..134ba717 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -20,8 +21,7 @@ public PolicyScopeBuilder(string prefix, AccessPolicyRegistry registry, IService public IPolicyScopeBuilder RequireAuthenticated() => Add(); public IPolicyScopeBuilder RequireSelf() => Add(); - public IPolicyScopeBuilder RequireAdmin() => Add(); - public IPolicyScopeBuilder RequireSelfOrAdmin() => Add(); + public IPolicyScopeBuilder RequirePermission() => Add(); public IPolicyScopeBuilder DenyCrossTenant() => Add(); private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy 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/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 index fa9bc520..ba1cbd35 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; namespace CodeBeam.UltimateAuth.Policies; @@ -32,14 +33,14 @@ public bool AppliesTo(AccessContext context) if (!context.IsAuthenticated || context.IsSystemActor) return false; - return !AllowedForInactive.Any(prefix => context.Action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (context.Action.EndsWith(".anonymous")) + return false; + + return !AllowedForInactive.Contains(context.Action); } private static readonly string[] AllowedForInactive = { - "users.status.change.", - "credentials.password.reset.", - "login.", - "reauth." + UAuthActions.Users.ChangeStatusSelf, }; } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs deleted file mode 100644 index 97702678..00000000 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Policies; - -internal sealed class RequireAdminPolicy : IAccessPolicy -{ - public AccessDecision Decide(AccessContext context) - { - if (!context.IsAuthenticated) - return AccessDecision.Deny("unauthenticated"); - - if (!context.Attributes.TryGetValue("roles", out var value)) - return AccessDecision.Deny("missing_roles"); - - if (value is not IReadOnlyCollection roles) - return AccessDecision.Deny("invalid_roles"); - - return roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) - ? AccessDecision.Allow() - : AccessDecision.Deny("admin_required"); - } - - public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".admin"); -} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs index 5e23dab6..cbe238d4 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using System.Net; namespace CodeBeam.UltimateAuth.Policies; @@ -12,5 +14,8 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("unauthenticated"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) + { + return !context.Action.EndsWith(".anonymous"); + } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs deleted file mode 100644 index 52e75a09..00000000 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Policies; - -internal sealed class RequireSelfOrAdminPolicy : IAccessPolicy -{ - public AccessDecision Decide(AccessContext context) - { - if (!context.IsAuthenticated) - return AccessDecision.Deny("unauthenticated"); - - if (context.IsSelfAction) - return AccessDecision.Allow(); - - if (context.Attributes.TryGetValue("roles", out var value) - && value is IReadOnlyCollection roles - && roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) - { - return AccessDecision.Allow(); - } - - return AccessDecision.Deny("self_or_admin_required"); - } - - public bool AppliesTo(AccessContext context) => !context.Action.EndsWith(".self") && !context.Action.EndsWith(".admin"); -} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs index ae6a4dd4..595fde6b 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs @@ -15,5 +15,8 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("not_self"); } - public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".self"); + public bool AppliesTo(AccessContext context) + { + return context.Action.EndsWith(".self"); + } } diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index c0d2dca4..3e3e4116 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using Konscious.Security.Cryptography; namespace CodeBeam.UltimateAuth.Security.Argon2; @@ -17,7 +18,7 @@ public Argon2PasswordHasher(Argon2Options options) public string Hash(string password) { if (string.IsNullOrEmpty(password)) - throw new ArgumentException("Password cannot be null or empty.", nameof(password)); + throw new UAuthValidationException("Password cannot be null or empty."); var salt = RandomNumberGenerator.GetBytes(_options.SaltSize); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index 16666c7b..b7f0977d 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -33,37 +33,29 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.UserKey) .IsRequired(); - e.HasIndex(x => new { x.Tenant, x.UserKey }) - .IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.RootId }).IsUnique(); e.Property(x => x.SecurityVersion) .IsRequired(); - e.Property(x => x.LastUpdatedAt) - .IsRequired(); - e.Property(x => x.RootId) .HasConversion( v => v.Value, v => SessionRootId.From(v)) .IsRequired(); - - e.HasIndex(x => new { x.Tenant, x.RootId }); - }); b.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.UserKey) .IsRequired(); @@ -90,7 +82,7 @@ protected override void OnModelCreating(ModelBuilder b) b.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion).IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.HasIndex(x => new { x.Tenant, x.SessionId }).IsUnique(); e.HasIndex(x => new { x.Tenant, x.ChainId, x.RevokedAt }); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index 20358cf6..7106b024 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -8,20 +8,23 @@ internal sealed class SessionChainProjection public long Id { get; set; } public SessionChainId ChainId { get; set; } = default!; - public SessionRootId RootId { get; } - + public SessionRootId RootId { get; set; } public TenantKey Tenant { get; set; } public UserKey UserKey { get; set; } - public int RotationCount { get; set; } - public long SecurityVersionAtCreation { get; set; } - + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastSeenAt { get; set; } + public DateTimeOffset? AbsoluteExpiresAt { get; set; } + public DeviceContext Device { get; set; } 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 bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } + public long Version { get; set; } - public byte[] RowVersion { get; set; } = default!; + 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/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs index fdf642ed..58c37604 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -26,5 +26,5 @@ internal sealed class SessionProjection public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs index 05be286e..5d1e613f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -10,11 +10,12 @@ internal sealed class SessionRootProjection public TenantKey Tenant { get; set; } public UserKey UserKey { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } public long SecurityVersion { get; set; } - public DateTimeOffset LastUpdatedAt { get; set; } - - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 6f0f3e80..478a44e6 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull { services.AddDbContext(configureDb); - services.AddScoped(); + 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 index 93c582f4..683f9453 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -11,12 +11,17 @@ public static UAuthSessionChain ToDomain(this SessionChainProjection p) p.RootId, p.Tenant, p.UserKey, - p.RotationCount, - p.SecurityVersionAtCreation, + p.CreatedAt, + p.LastSeenAt, + p.AbsoluteExpiresAt, + p.Device, p.ClaimsSnapshot, p.ActiveSessionId, - p.IsRevoked, - p.RevokedAt + p.RotationCount, + p.TouchCount, + p.SecurityVersionAtCreation, + p.RevokedAt, + p.Version ); } @@ -25,17 +30,20 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) return new SessionChainProjection { ChainId = chain.ChainId, + RootId = chain.RootId, Tenant = chain.Tenant, UserKey = chain.UserKey, - - RotationCount = chain.RotationCount, - SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + CreatedAt = chain.CreatedAt, + LastSeenAt = chain.LastSeenAt, + AbsoluteExpiresAt = chain.AbsoluteExpiresAt, + Device = chain.Device, ClaimsSnapshot = chain.ClaimsSnapshot, - ActiveSessionId = chain.ActiveSessionId, - - IsRevoked = chain.IsRevoked, - RevokedAt = chain.RevokedAt + RotationCount = chain.RotationCount, + TouchCount = chain.TouchCount, + SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + RevokedAt = chain.RevokedAt, + Version = chain.Version }; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index da2a7fe8..9b591691 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -13,13 +13,12 @@ public static UAuthSession ToDomain(this SessionProjection p) p.ChainId, p.CreatedAt, p.ExpiresAt, - p.LastSeenAt, p.IsRevoked, p.RevokedAt, p.SecurityVersionAtCreation, - p.Device, p.Claims, - p.Metadata + p.Metadata, + p.Version ); } @@ -34,15 +33,14 @@ public static SessionProjection ToProjection(this UAuthSession s) CreatedAt = s.CreatedAt, ExpiresAt = s.ExpiresAt, - LastSeenAt = s.LastSeenAt, IsRevoked = s.IsRevoked, RevokedAt = s.RevokedAt, SecurityVersionAtCreation = s.SecurityVersionAtCreation, - Device = s.Device, Claims = s.Claims, - Metadata = s.Metadata + Metadata = s.Metadata, + Version = s.Version }; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index a0c223ae..63d1fa41 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -4,17 +4,18 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; internal static class SessionRootProjectionMapper { - public static UAuthSessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList? chains = null) + public static UAuthSessionRoot ToDomain(this SessionRootProjection root) { return UAuthSessionRoot.FromProjection( root.RootId, root.Tenant, root.UserKey, + root.CreatedAt, + root.UpdatedAt, root.IsRevoked, root.RevokedAt, root.SecurityVersion, - chains ?? Array.Empty(), - root.LastUpdatedAt + root.Version ); } @@ -26,11 +27,14 @@ public static SessionRootProjection ToProjection(this UAuthSessionRoot root) Tenant = root.Tenant, UserKey = root.UserKey, + CreatedAt = root.CreatedAt, + UpdatedAt = root.UpdatedAt, + IsRevoked = root.IsRevoked, RevokedAt = root.RevokedAt, SecurityVersion = root.SecurityVersion, - LastUpdatedAt = root.LastUpdatedAt + Version = root.Version }; } } 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..65a2a1db --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -0,0 +1,612 @@ +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 +{ + private readonly UltimateAuthSessionDbContext _db; + private readonly TenantContext _tenant; + + public EfCoreSessionStore(UltimateAuthSessionDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + 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; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + + public async Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Sessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.SessionId == sessionId); + + return projection?.ToDomain(); + } + + public async Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = session.ToProjection(); + projection.Version = expectedVersion; + + _db.Entry(projection).State = EntityState.Modified; + _db.Entry(projection).Property(x => x.Version).OriginalValue = expectedVersion; + + try + { + await Task.CompletedTask; + } + catch (DbUpdateConcurrencyException) + { + throw new UAuthConcurrencyException("session_concurrency_conflict"); + } + } + + public async 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."); + + _db.Sessions.Add(projection); + } + + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); + + if (projection is null) + return false; + + var session = projection.ToDomain(); + if (session.IsRevoked) + return false; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + + return true; + } + + public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chains = await _db.Chains.Where(x => x.UserKey == user).ToListAsync(ct); + + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); + + var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + } + + public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chains = await _db.Chains.Where(x => x.UserKey == user && x.ChainId != keepChain).ToListAsync(ct); + + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); + + var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + } + + public async Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains + .AsNoTracking() + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + return projection?.ToDomain(); + } + + public async Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.RevokedAt == null && + x.Device.DeviceId == deviceId) + .SingleOrDefaultAsync(ct); + + return projection?.ToDomain(); + } + + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = chain.ToProjection(); + + if (chain.Version != expectedVersion + 1) + throw new InvalidOperationException("Chain version must be incremented by domain."); + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + return Task.CompletedTask; + } + + 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(); + + _db.Chains.Add(projection); + + return Task.CompletedTask; + } + + public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + var chain = projection.ToDomain(); + if (chain.IsRevoked) + return; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + + public async Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chainProjection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId, ct); + + if (chainProjection is null) + return; + + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + return; + + var sessions = await _db.Sessions.Where(x => x.ChainId == chainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + + public async Task RevokeOtherChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId currentChainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Chains + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.ChainId != currentChainId && + !x.IsRevoked) + .ToListAsync(ct); + + foreach (var projection in projections) + { + var chain = projection.ToDomain(); + + if (chain.IsRevoked) + continue; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + } + + public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Chains + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + !x.IsRevoked) + .ToListAsync(ct); + + foreach (var projection in projections) + { + var chain = projection.ToDomain(); + + if (chain.IsRevoked) + continue; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + } + + public async Task GetActiveSessionIdAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Chains + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .Select(x => x.ActiveSessionId) + .SingleOrDefaultAsync(); + } + + public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + projection.ActiveSessionId = sessionId; + _db.Chains.Update(projection); + } + + public async Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.UserKey == userKey) + .ToListAsync(); + + return rootProjection.ToDomain(); + } + + public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = root.ToProjection(); + + if (root.Version != expectedVersion + 1) + throw new InvalidOperationException("Root version must be incremented by domain."); + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + return Task.CompletedTask; + } + + 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(); + + _db.Roots.Add(projection); + + return Task.CompletedTask; + } + + public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (projection is null) + return; + + var root = projection.ToDomain(); + _db.Roots.Update(root.Revoke(at).ToProjection()); + } + + public async Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Sessions + .AsNoTracking() + .Where(x => 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 = _db.Roots.AsNoTracking().Where(r => r.UserKey == userKey); + + if (!includeHistoricalRoots) + { + rootsQuery = rootsQuery.Where(r => !r.IsRevoked); + } + + var rootIds = await rootsQuery.Select(r => r.RootId).ToListAsync(); + + if (rootIds.Count == 0) + return Array.Empty(); + + var projections = await _db.Chains.AsNoTracking().Where(c => rootIds.Contains(c.RootId)).ToListAsync(); + return projections.Select(c => c.ToDomain()).ToList(); + } + + public async Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Chains + .AsNoTracking() + .Where(x => 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 _db.Sessions + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.RootId == rootId); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.RootId == rootId) + .ToListAsync(); + + return rootProjection.ToDomain(); + } + + public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); + + if (projection is null) + return; + + _db.Sessions.Remove(projection); + } + + public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chainProjection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (chainProjection is null) + return; + + var sessionProjections = await _db.Sessions.Where(x => x.ChainId == chainId && !x.IsRevoked).ToListAsync(); + + foreach (var sessionProjection in sessionProjections) + { + var session = sessionProjection.ToDomain(); + var revoked = session.Revoke(at); + + _db.Sessions.Update(revoked.ToProjection()); + } + + if (!chainProjection.IsRevoked) + { + var chain = chainProjection.ToDomain(); + var revokedChain = chain.Revoke(at); + + _db.Chains.Update(revokedChain.ToProjection()); + } + } + + public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (rootProjection is null) + return; + + var chainProjections = await _db.Chains.Where(x => x.UserKey == userKey).ToListAsync(); + + foreach (var chainProjection in chainProjections) + { + var chainId = chainProjection.ChainId; + + var sessionProjections = await _db.Sessions.Where(x => x.ChainId == chainId && !x.IsRevoked).ToListAsync(); + + foreach (var sessionProjection in sessionProjections) + { + var session = sessionProjection.ToDomain(); + var revokedSession = session.Revoke(at); + + _db.Sessions.Update(revokedSession.ToProjection()); + } + + if (!chainProjection.IsRevoked) + { + var chain = chainProjection.ToDomain(); + var revokedChain = chain.Revoke(at); + + _db.Chains.Update(revokedChain.ToProjection()); + } + } + + if (!rootProjection.IsRevoked) + { + var root = rootProjection.ToDomain(); + var revokedRoot = root.Revoke(at); + + _db.Roots.Update(revokedRoot.ToProjection()); + } + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs similarity index 70% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs index 240b9b9d..9200b8ef 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs @@ -4,18 +4,18 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -public sealed class EfCoreSessionStoreKernelFactory : ISessionStoreKernelFactory +public sealed class EfCoreSessionStoreFactory : ISessionStoreFactory { private readonly IServiceProvider _sp; - public EfCoreSessionStoreKernelFactory(IServiceProvider sp) + public EfCoreSessionStoreFactory(IServiceProvider sp) { _sp = sp; } - public ISessionStoreKernel Create(TenantKey tenant) + public ISessionStore Create(TenantKey tenant) { - return ActivatorUtilities.CreateInstance(_sp, new TenantContext(tenant)); + return ActivatorUtilities.CreateInstance(_sp, new TenantContext(tenant)); } // TODO: Implement global here diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs deleted file mode 100644 index bf34a06f..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs +++ /dev/null @@ -1,281 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.EntityFrameworkCore; -using System.Data; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; - -internal sealed class EfCoreSessionStoreKernel : ISessionStoreKernel -{ - private readonly UltimateAuthSessionDbContext _db; - private readonly TenantContext _tenant; - - public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db, TenantContext tenant) - { - _db = db; - _tenant = tenant; - } - - public async Task ExecuteAsync(Func action, CancellationToken ct = default) - { - var strategy = _db.Database.CreateExecutionStrategy(); - - await strategy.ExecuteAsync(async () => - { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); - - try - { - await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - finally - { - _db.Database.UseTransaction(null); - } - }); - } - - public async Task ExecuteAsync(Func> action, CancellationToken ct = default) - { - var strategy = _db.Database.CreateExecutionStrategy(); - - return await strategy.ExecuteAsync(async () => - { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); - - try - { - var result = await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - return result; - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - finally - { - _db.Database.UseTransaction(null); - } - }); - } - - public async Task GetSessionAsync(AuthSessionId sessionId) - { - var projection = await _db.Sessions - .AsNoTracking() - .SingleOrDefaultAsync(x => x.SessionId == sessionId); - - return projection?.ToDomain(); - } - - public async Task SaveSessionAsync(UAuthSession session) - { - var projection = session.ToProjection(); - - var exists = await _db.Sessions - .AnyAsync(x => x.SessionId == session.SessionId); - - if (exists) - _db.Sessions.Update(projection); - else - _db.Sessions.Add(projection); - } - - public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) - { - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); - - if (projection is null) - return false; - - var session = projection.ToDomain(); - if (session.IsRevoked) - return false; - - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - - return true; - } - - public async Task GetChainAsync(SessionChainId chainId) - { - var projection = await _db.Chains - .AsNoTracking() - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - return projection?.ToDomain(); - } - - public async Task SaveChainAsync(UAuthSessionChain chain) - { - var projection = chain.ToProjection(); - - var exists = await _db.Chains - .AnyAsync(x => x.ChainId == chain.ChainId); - - if (exists) - _db.Chains.Update(projection); - else - _db.Chains.Add(projection); - } - - public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) - { - var projection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - if (projection is null) - return; - - var chain = projection.ToDomain(); - if (chain.IsRevoked) - return; - - _db.Chains.Update(chain.Revoke(at).ToProjection()); - } - - public async Task GetActiveSessionIdAsync(SessionChainId chainId) - { - return await _db.Chains - .AsNoTracking() - .Where(x => x.ChainId == chainId) - .Select(x => x.ActiveSessionId) - .SingleOrDefaultAsync(); - } - - public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) - { - var projection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - if (projection is null) - return; - - projection.ActiveSessionId = sessionId; - _db.Chains.Update(projection); - } - - public async Task GetSessionRootByUserAsync(UserKey userKey) - { - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); - } - - public async Task SaveSessionRootAsync(UAuthSessionRoot root) - { - var projection = root.ToProjection(); - - var exists = await _db.Roots - .AnyAsync(x => x.RootId == root.RootId); - - if (exists) - _db.Roots.Update(projection); - else - _db.Roots.Add(projection); - } - - public async Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) - { - var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (projection is null) - return; - - var root = projection.ToDomain(); - _db.Roots.Update(root.Revoke(at).ToProjection()); - } - - public async Task GetChainIdBySessionAsync(AuthSessionId sessionId) - { - return await _db.Sessions - .AsNoTracking() - .Where(x => x.SessionId == sessionId) - .Select(x => (SessionChainId?)x.ChainId) - .SingleOrDefaultAsync(); - } - - public async Task> GetChainsByUserAsync(UserKey userKey) - { - var projections = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return projections.Select(x => x.ToDomain()).ToList(); - } - - public async Task> GetSessionsByChainAsync(SessionChainId chainId) - { - var projections = await _db.Sessions - .AsNoTracking() - .Where(x => x.ChainId == chainId) - .ToListAsync(); - - return projections.Select(x => x.ToDomain()).ToList(); - } - - public async Task GetSessionRootByIdAsync(SessionRootId rootId) - { - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.RootId == rootId); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.RootId == rootId) - .ToListAsync(); - - return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); - } - - - public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) - { - var projections = await _db.Sessions - .Where(x => x.ExpiresAt <= at && !x.IsRevoked) - .ToListAsync(); - - foreach (var p in projections) - { - var revoked = p.ToDomain().Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } - } - -} 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..73fc9452 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -0,0 +1,509 @@ +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 ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _chains = new(); + private readonly ConcurrentDictionary _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(TenantKey tenant, 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(TenantKey tenant, 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(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(root.UserKey, out var current)) + throw new UAuthNotFoundException("root_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("root_concurrency_conflict"); + + _roots[root.UserKey] = root; + return Task.CompletedTask; + } + + public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (_roots.ContainsKey(root.UserKey)) + throw new UAuthConcurrencyException("root_already_exists"); + + if (root.Version != 0) + throw new InvalidOperationException("New root must have version 0."); + + _roots[root.UserKey] = root; + } + + return Task.CompletedTask; + } + + public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_roots.TryGetValue(userKey, out var root)) + { + _roots[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(TenantKey tenant, 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(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[userKey] = revokedRoot; + } + } + + return Task.CompletedTask; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs similarity index 63% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs index 6bd845eb..af6b5e99 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs @@ -4,12 +4,12 @@ namespace CodeBeam.UltimateAuth.Sessions.InMemory; -public sealed class InMemorySessionStoreKernelFactory : ISessionStoreKernelFactory +public sealed class InMemorySessionStoreFactory : ISessionStoreFactory { - private readonly ConcurrentDictionary _kernels = new(); + private readonly ConcurrentDictionary _kernels = new(); - public ISessionStoreKernel Create(TenantKey tenant) + public ISessionStore Create(TenantKey tenant) { - return _kernels.GetOrAdd(tenant, _ => new InMemorySessionStoreKernel()); + return _kernels.GetOrAdd(tenant, _ => new InMemorySessionStore()); } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs deleted file mode 100644 index 893a73b0..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs +++ /dev/null @@ -1,155 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using System.Collections.Concurrent; - -namespace CodeBeam.UltimateAuth.Sessions.InMemory; - -internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel -{ - private readonly SemaphoreSlim _tx = new(1, 1); - - private readonly ConcurrentDictionary _sessions = new(); - private readonly ConcurrentDictionary _chains = new(); - private readonly ConcurrentDictionary _roots = new(); - private readonly ConcurrentDictionary _activeSessions = 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) => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); - - public Task SaveSessionAsync(UAuthSession session) - { - _sessions[session.SessionId] = session; - return Task.CompletedTask; - } - - public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) - { - if (!_sessions.TryGetValue(sessionId, out var session)) - return Task.FromResult(false); - - if (session.IsRevoked) - return Task.FromResult(false); - - _sessions[sessionId] = session.Revoke(at); - return Task.FromResult(true); - } - - public Task GetChainAsync(SessionChainId chainId) - => Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); - - public Task SaveChainAsync(UAuthSessionChain chain) - { - _chains[chain.ChainId] = chain; - return Task.CompletedTask; - } - - public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) - { - if (_chains.TryGetValue(chainId, out var chain)) - { - _chains[chainId] = chain.Revoke(at); - } - return Task.CompletedTask; - } - - public Task GetActiveSessionIdAsync(SessionChainId chainId) - => Task.FromResult(_activeSessions.TryGetValue(chainId, out var id) ? id : null); - - public Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) - { - _activeSessions[chainId] = sessionId; - return Task.CompletedTask; - } - - public Task GetSessionRootByUserAsync(UserKey userKey) - => Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); - - public Task GetSessionRootByIdAsync(SessionRootId rootId) - => Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); - - public Task SaveSessionRootAsync(UAuthSessionRoot root) - { - _roots[root.UserKey] = root; - return Task.CompletedTask; - } - - public Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) - { - if (_roots.TryGetValue(userKey, out var root)) - { - _roots[userKey] = root.Revoke(at); - } - return Task.CompletedTask; - } - - public Task GetChainIdBySessionAsync(AuthSessionId sessionId) - { - if (_sessions.TryGetValue(sessionId, out var session)) - return Task.FromResult(session.ChainId); - - return Task.FromResult(null); - } - - public Task> GetChainsByUserAsync(UserKey userKey) - { - if (!_roots.TryGetValue(userKey, out var root)) - return Task.FromResult>(Array.Empty()); - - return Task.FromResult>(root.Chains.ToList()); - } - - public Task> GetSessionsByChainAsync(SessionChainId chainId) - { - var result = _sessions.Values - .Where(s => s.ChainId == chainId) - .ToList(); - - return Task.FromResult>(result); - } - - public Task DeleteExpiredSessionsAsync(DateTimeOffset at) - { - foreach (var kvp in _sessions) - { - var session = kvp.Value; - - if (session.ExpiresAt <= at) - { - var revoked = session.Revoke(at); - _sessions[kvp.Key] = revoked; - - if (_activeSessions.TryGetValue(revoked.ChainId, out var activeId) && activeId == revoked.SessionId) - { - _activeSessions.TryRemove(revoked.ChainId, out _); - } - } - } - - return Task.CompletedTask; - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs index aebffb25..054fcffc 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index 138f800d..d1167f36 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -20,5 +20,5 @@ internal sealed class RefreshTokenProjection public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset? RevokedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; + 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 index 0dc5a1e5..72418bec 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs @@ -12,5 +12,5 @@ internal sealed class RevokedTokenIdProjection public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset RevokedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs index be84d6e1..f8c371ea 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -18,8 +18,7 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.TokenHash) .IsRequired(); @@ -40,8 +39,7 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.Jti) .IsRequired(); 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..7906dd6e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierExistenceQuery( + TenantKey Tenant, + 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/SelfUserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs new file mode 100644 index 00000000..752fc7eb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum SelfUserStatus +{ + 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/UserProfileSnapshot.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs similarity index 100% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileSnapshot.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs index a4d91eec..e8fc7fa7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs @@ -1,12 +1,16 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; -public sealed record UserIdentifierDto +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserIdentifierDto : 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/UserQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs new file mode 100644 index 00000000..f3a44e63 --- /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 class 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/UserViewDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs similarity index 63% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs index ce89dea8..b245402d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs @@ -1,8 +1,12 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; -public sealed record UserViewDto +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserView { - public string UserKey { get; init; } = default!; + public UserKey UserKey { get; init; } = default!; + public UserStatus Status { get; init; } public string? UserName { get; init; } public string? PrimaryEmail { get; init; } @@ -14,6 +18,9 @@ public sealed record UserViewDto 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; } 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..b1b30e2b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public static class UserStatusMapper +{ + public static UserStatus ToUserStatus(this SelfUserStatus selfStatus) + { + switch (selfStatus) + { + case SelfUserStatus.Active: + return UserStatus.Active; + case SelfUserStatus.SelfSuspended: + return UserStatus.SelfSuspended; + default: + throw new NotImplementedException(); + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs index 5b7561e3..f3511af5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -1,9 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed class ChangeUserStatusAdminRequest { - public required UserKey UserKey { get; init; } public required UserStatus 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 index dba43740..f9c82d6e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs @@ -2,5 +2,5 @@ public class ChangeUserStatusSelfRequest { - public required UserStatus NewStatus { get; init; } + public required SelfUserStatus 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 index ff720ce8..e078eb69 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs @@ -2,6 +2,13 @@ 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; } @@ -15,8 +22,4 @@ public sealed record CreateUserRequest public string? TimeZone { get; init; } public string? Culture { get; init; } public IReadOnlyDictionary? Metadata { get; init; } - - public UserIdentifierType? PrimaryIdentifierType { get; init; } - public string? PrimaryIdentifierValue { get; init; } - public bool PrimaryIdentifierVerified { get; init; } = false; } 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..c2d9ef05 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierExistsRequest +{ + public UserIdentifierType Type { get; set; } + public string Value { get; set; } = default!; +} 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..4d744bd7 --- /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 class LogoutDeviceRequest +{ + public required SessionChainId ChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs new file mode 100644 index 00000000..7865c876 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutOtherDevicesAdminRequest +{ + public required SessionChainId CurrentChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs new file mode 100644 index 00000000..6f6b4e97 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutOtherDevicesSelfRequest +{ + public required SessionChainId CurrentChainId { 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..f425570d --- /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 class UserIdentifierQuery : PageRequest +{ + public UserKey? UserKey { get; set; } + + public bool IncludeDeleted { get; init; } = false; +} 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/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/UserStatusChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs index e355cd9a..25706af4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserStatusChangeResult { diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index 01db0848..9492398b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -14,9 +14,6 @@ public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceColle services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddScoped(); - services.TryAddScoped(); services.TryAddSingleton, InMemoryUserIdProvider>(); // Seed never try add diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs deleted file mode 100644 index 71ec819a..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityState : IUserSecurityState -{ - public long SecurityVersion { get; init; } - public int FailedLoginAttempts { get; init; } - public DateTimeOffset? LockedUntil { get; init; } - public bool RequiresReauthentication { get; init; } - public DateTimeOffset? LastFailedAt { get; init; } - - public bool IsLocked => LockedUntil.HasValue && LockedUntil.Value > DateTimeOffset.UtcNow; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs deleted file mode 100644 index b0331abd..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider -{ - private readonly InMemoryUserSecurityStore _store; - - public InMemoryUserSecurityStateProvider(InMemoryUserSecurityStore store) - { - _store = store; - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.Get(tenant, userKey)); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs deleted file mode 100644 index a97f1f44..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs +++ /dev/null @@ -1,53 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStateWriter : IUserSecurityStateWriter -{ - private readonly InMemoryUserSecurityStore _store; - - public InMemoryUserSecurityStateWriter(InMemoryUserSecurityStore store) - { - _store = store; - } - - public Task RecordFailedLoginAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) - { - var current = _store.Get(tenant, userKey); - - var next = new InMemoryUserSecurityState - { - SecurityVersion = (current?.SecurityVersion ?? 0) + 1, - FailedLoginAttempts = (current?.FailedLoginAttempts ?? 0) + 1, - LockedUntil = current?.LockedUntil, - RequiresReauthentication = current?.RequiresReauthentication ?? false, - LastFailedAt = at - }; - - _store.Set(tenant, userKey, next); - return Task.CompletedTask; - } - - public Task LockUntilAsync(TenantKey tenant, UserKey userKey, DateTimeOffset lockedUntil, CancellationToken ct = default) - { - var current = _store.Get(tenant, userKey); - - var next = new InMemoryUserSecurityState - { - SecurityVersion = (current?.SecurityVersion ?? 0) + 1, - FailedLoginAttempts = current?.FailedLoginAttempts ?? 0, - LockedUntil = lockedUntil, - RequiresReauthentication = current?.RequiresReauthentication ?? false - }; - - _store.Set(tenant, userKey, next); - return Task.CompletedTask; - } - - public Task ResetFailuresAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - _store.Clear(tenant, userKey); - return Task.CompletedTask; - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index c5312f51..2335b208 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; @@ -15,6 +16,7 @@ internal sealed class InMemoryUserSeedContributor : ISeedContributor private readonly IUserProfileStore _profiles; private readonly IUserIdentifierStore _identifiers; private readonly IInMemoryUserIdProvider _ids; + private readonly IIdentifierNormalizer _identifierNormalizer; private readonly IClock _clock; public InMemoryUserSeedContributor( @@ -22,12 +24,14 @@ public InMemoryUserSeedContributor( IUserProfileStore profiles, IUserIdentifierStore identifiers, IInMemoryUserIdProvider ids, + IIdentifierNormalizer identifierNormalizer, IClock clock) { _lifecycle = lifecycle; _profiles = profiles; _ids = ids; _identifiers = identifiers; + _identifierNormalizer = identifierNormalizer; _clock = clock; } @@ -37,67 +41,49 @@ public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) await SeedUserAsync(tenant, _ids.GetUserUserId(), "Standard User", "user", "user@ultimateauth.com", "9876543210", ct); } - private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string primaryUsername, - string primaryEmail, string primaryPhone, CancellationToken ct) + private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string username, string email, string phone, CancellationToken ct) { - if (await _lifecycle.ExistsAsync(tenant, userKey, ct)) + var userLifecycleKey = new UserLifecycleKey(tenant, userKey); + if (await _lifecycle.ExistsAsync(userLifecycleKey, ct)) return; - await _lifecycle.CreateAsync(tenant, - new UserLifecycle - { - Tenant = tenant, - UserKey = userKey, - Status = UserStatus.Active, - CreatedAt = _clock.UtcNow - }, ct); + await _lifecycle.AddAsync(UserLifecycle.Create(tenant, userKey, _clock.UtcNow), ct); + await _profiles.AddAsync(UserProfile.Create(_clock.UtcNow, tenant, userKey, displayName: displayName), ct); - await _profiles.CreateAsync(tenant, - new UserProfile - { - Tenant = tenant, - UserKey = userKey, - DisplayName = displayName, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + username, + _identifierNormalizer.Normalize(UserIdentifierType.Username, username).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Username, - Value = primaryUsername, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Email, + email, + _identifierNormalizer.Normalize(UserIdentifierType.Email, email).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Email, - Value = primaryEmail, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); - - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Phone, - Value = primaryPhone, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Phone, + phone, + _identifierNormalizer.Normalize(UserIdentifierType.Phone, phone).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index b18a1272..b99c7604 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +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; @@ -7,54 +8,72 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore +public sealed class InMemoryUserIdentifierStore : InMemoryVersionedStore, IUserIdentifierStore { - private readonly Dictionary _store = new(); + protected override Guid GetKey(UserIdentifier entity) => entity.Id; + private readonly object _primaryLock = new(); - public Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) + public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var exists = _store.Values.Any(x => - x.Tenant == tenant && - x.Type == type && - x.Value == value && - !x.IsDeleted); + var candidates = Values() + .Where(x => + x.Tenant == query.Tenant && + x.Type == query.Type && + x.NormalizedValue == query.NormalizedValue && + !x.IsDeleted); - return Task.FromResult(exists); + 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(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) + public Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var identifier = _store.Values.FirstOrDefault(x => - x.Tenant == tenant && - x.Type == type && - x.Value == value && - !x.IsDeleted); + var identifier = Values() + .FirstOrDefault(x => + x.Tenant == tenant && + x.Type == type && + x.NormalizedValue == normalizedValue && + !x.IsDeleted); return Task.FromResult(identifier); } public Task GetByIdAsync(Guid id, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - if (!_store.TryGetValue(id, out var identifier)) - return Task.FromResult(null); - - if (identifier.IsDeleted) - return Task.FromResult(null); - - return Task.FromResult(identifier); + return GetAsync(id, ct); } public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var result = _store.Values + var result = Values() .Where(x => x.Tenant == tenant) .Where(x => x.UserKey == userKey) .Where(x => !x.IsDeleted) @@ -65,155 +84,145 @@ public Task> GetByUserAsync(TenantKey tenant, User return Task.FromResult>(result); } - public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default) + protected override void BeforeAdd(UserIdentifier entity) { - ct.ThrowIfCancellationRequested(); - - if (identifier.Id == Guid.Empty) - identifier.Id = Guid.NewGuid(); - - var duplicate = _store.Values.Any(x => - x.Tenant == tenant && - x.Type == identifier.Type && - x.Value == identifier.Value && - !x.IsDeleted); - - if (duplicate) - throw new UAuthConflictException("identifier_already_exists"); - - identifier.Tenant = tenant; - - _store[identifier.Id] = identifier; - - return Task.CompletedTask; + 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 Task UpdateValueAsync(Guid id, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) + public override Task AddAsync(UserIdentifier entity, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); - - if (identifier.Value == newValue) - throw new InvalidOperationException("identifier_value_unchanged"); - - var duplicate = _store.Values.Any(x => - x.Id != id && - x.Tenant == identifier.Tenant && - x.Type == identifier.Type && - x.Value == newValue && - !x.IsDeleted); - - if (duplicate) - throw new InvalidOperationException("identifier_value_already_exists"); + lock (_primaryLock) + { + return base.AddAsync(entity, ct); + } + } - identifier.Value = newValue; - identifier.IsVerified = false; - identifier.VerifiedAt = null; - identifier.UpdatedAt = updatedAt; + 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); + } + } - return Task.CompletedTask; + public override Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) + { + lock (_primaryLock) + { + return base.SaveAsync(entity, expectedVersion, ct); + } } - public Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default) + public Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); - if (identifier.IsVerified) - return Task.CompletedTask; + var normalized = query.Normalize(); - identifier.IsVerified = true; - identifier.VerifiedAt = verifiedAt; - identifier.UpdatedAt = verifiedAt; + var baseQuery = Values() + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == query.UserKey.Value); - return Task.CompletedTask; - } + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); - public Task SetPrimaryAsync(Guid id, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + baseQuery = query.SortBy switch + { + nameof(UserIdentifier.Type) => query.Descending + ? baseQuery.OrderByDescending(x => x.Type) + : baseQuery.OrderBy(x => x.Type), - if (!_store.TryGetValue(id, out var target) || target.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + nameof(UserIdentifier.CreatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), - foreach (var idf in _store.Values.Where(x => - x.Tenant == target.Tenant && - x.UserKey == target.UserKey && - x.Type == target.Type && - x.IsPrimary)) - { - idf.IsPrimary = false; - } + nameof(UserIdentifier.UpdatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.UpdatedAt) + : baseQuery.OrderBy(x => x.UpdatedAt), - target.IsPrimary = true; + nameof(UserIdentifier.Value) => query.Descending + ? baseQuery.OrderByDescending(x => x.Value) + : baseQuery.OrderBy(x => x.Value), - return Task.CompletedTask; - } + nameof(UserIdentifier.NormalizedValue) => query.Descending + ? baseQuery.OrderByDescending(x => x.NormalizedValue) + : baseQuery.OrderBy(x => x.NormalizedValue), - public Task UnsetPrimaryAsync(Guid id, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); - identifier.IsPrimary = false; - identifier.UpdatedAt = DateTimeOffset.UtcNow; + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); - return Task.CompletedTask; } - public Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(id, out var identifier)) - return Task.CompletedTask; - - if (mode == DeleteMode.Hard) - { - _store.Remove(id); - return Task.CompletedTask; - } - - if (identifier.IsDeleted) - return Task.CompletedTask; + var set = userKeys.ToHashSet(); - identifier.IsDeleted = true; - identifier.DeletedAt = deletedAt; - identifier.IsPrimary = false; - identifier.UpdatedAt = deletedAt; + var result = Values() + .Where(x => x.Tenant == tenant) + .Where(x => set.Contains(x.UserKey)) + .Where(x => !x.IsDeleted) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); - return Task.CompletedTask; + return Task.FromResult>(result); } - public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var identifiers = _store.Values.Where(x => x.Tenant == tenant && x.UserKey == userKey).ToList(); + var identifiers = Values() + .Where(x => x.Tenant == tenant && x.UserKey == userKey && !x.IsDeleted) + .ToList(); foreach (var identifier in identifiers) { - if (mode == DeleteMode.Hard) - { - _store.Remove(identifier.Id); - } - else - { - if (identifier.IsDeleted) - continue; - - identifier.IsDeleted = true; - identifier.DeletedAt = deletedAt; - identifier.IsPrimary = false; - } + await DeleteAsync(identifier.Id, identifier.Version, mode, deletedAt, ct); } - - return Task.CompletedTask; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index aea09138..912fd90b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -1,35 +1,22 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserLifecycleStore : IUserLifecycleStore +public sealed class InMemoryUserLifecycleStore : InMemoryVersionedStore, IUserLifecycleStore { - private readonly Dictionary<(TenantKey, UserKey), UserLifecycle> _store = new(); + protected override UserLifecycleKey GetKey(UserLifecycle entity) + => new(entity.Tenant, entity.UserKey); - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.TryGetValue((tenant, userKey), out var entity) && !entity.IsDeleted); - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) { - if (!_store.TryGetValue((tenant, userKey), out var entity)) - return Task.FromResult(null); + ct.ThrowIfCancellationRequested(); - if (entity.IsDeleted) - return Task.FromResult(null); + var normalized = query.Normalize(); - return Task.FromResult(entity); - } - - public Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) - { - var baseQuery = _store.Values - .Where(x => x?.UserKey != null) + var baseQuery = Values() .Where(x => x.Tenant == tenant); if (!query.IncludeDeleted) @@ -38,74 +25,44 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc if (query.Status != null) baseQuery = baseQuery.Where(x => x.Status == query.Status); - var totalCount = baseQuery.Count(); - - var items = baseQuery - .OrderBy(x => x.CreatedAt) - .Skip(query.Skip) - .Take(query.Take) - .ToList() - .AsReadOnly(); - - return Task.FromResult(new PagedResult(items, totalCount)); - } - - public Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default) - { - var key = (tenant, lifecycle.UserKey); - - if (_store.ContainsKey(key)) - throw new InvalidOperationException("UserLifecycle already exists."); - - _store[key] = lifecycle; - return Task.CompletedTask; - } - - public Task ChangeStatusAsync(TenantKey tenant, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var entity) || entity.IsDeleted) - throw new InvalidOperationException("UserLifecycle not found."); - - entity.Status = newStatus; - entity.UpdatedAt = updatedAt; - - return Task.CompletedTask; - } - - public Task ChangeSecurityStampAsync(TenantKey tenant, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var entity) || entity.IsDeleted) - throw new InvalidOperationException("UserLifecycle not found."); - - if (entity.SecurityStamp == newSecurityStamp) - return Task.CompletedTask; - - entity.SecurityStamp = newSecurityStamp; - entity.UpdatedAt = updatedAt; - - return Task.CompletedTask; - } - - public Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) - { - var key = (tenant, userKey); - - if (!_store.TryGetValue(key, out var entity)) - return Task.CompletedTask; - - if (mode == DeleteMode.Hard) + baseQuery = query.SortBy switch { - _store.Remove(key); - return Task.CompletedTask; - } + 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.Tenant) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Tenant.Value) + : baseQuery.OrderBy(x => x.Tenant.Value), + + 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) + }; - // Soft delete (idempotent) - if (entity.IsDeleted) - return Task.CompletedTask; - - entity.IsDeleted = true; - entity.DeletedAt = deletedAt; + var totalCount = baseQuery.Count(); + var items = baseQuery.Skip((normalized.PageNumber - 1) * normalized.PageSize).Take(normalized.PageSize).ToList().AsReadOnly(); - return Task.CompletedTask; + 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/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index a902bc57..d24ff036 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,102 +1,85 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserProfileStore : IUserProfileStore +public sealed class InMemoryUserProfileStore : InMemoryVersionedStore, IUserProfileStore { - private readonly Dictionary<(TenantKey Tenant, UserKey UserKey), UserProfile> _store = new(); - - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.TryGetValue((tenant, userKey), out var profile) && profile.DeletedAt == null); - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var profile)) - return Task.FromResult(null); - - if (profile.DeletedAt != null) - return Task.FromResult(null); - - return Task.FromResult(profile); - } + protected override UserProfileKey GetKey(UserProfile entity) + => new(entity.Tenant, entity.UserKey); public Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) { - var baseQuery = _store.Values.Where(x => x.Tenant == tenant); + ct.ThrowIfCancellationRequested(); - if (!query.IncludeDeleted) - baseQuery = baseQuery.Where(x => x.DeletedAt == null); + var normalized = query.Normalize(); - var totalCount = baseQuery.Count(); + var baseQuery = Values() + .Where(x => x.Tenant == tenant); - var items = baseQuery - .OrderBy(x => x.CreatedAt) - .Skip(query.Skip) - .Take(query.Take) - .ToList() - .AsReadOnly(); - - return Task.FromResult(new PagedResult(items, totalCount)); - } + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); - public Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default) - { - var key = (tenant, profile.UserKey); + baseQuery = query.SortBy switch + { + nameof(UserProfile.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), - if (_store.ContainsKey(key)) - throw new InvalidOperationException("UserProfile already exists."); + nameof(UserProfile.DisplayName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DisplayName) + : baseQuery.OrderBy(x => x.DisplayName), - _store[key] = profile; - return Task.CompletedTask; - } + nameof(UserProfile.FirstName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.FirstName) + : baseQuery.OrderBy(x => x.FirstName), - public Task UpdateAsync(TenantKey tenant, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default) - { - var key = (tenant, userKey); + nameof(UserProfile.LastName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.LastName) + : baseQuery.OrderBy(x => x.LastName), - if (!_store.TryGetValue(key, out var existing) || existing.DeletedAt != null) - throw new InvalidOperationException("UserProfile not found."); + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; - existing.FirstName = update.FirstName; - existing.LastName = update.LastName; - existing.DisplayName = update.DisplayName; - existing.BirthDate = update.BirthDate; - existing.Gender = update.Gender; - existing.Bio = update.Bio; - existing.Language = update.Language; - existing.TimeZone = update.TimeZone; - existing.Culture = update.Culture; - existing.Metadata = update.Metadata; + var totalCount = baseQuery.Count(); - existing.UpdatedAt = updatedAt; + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToList() + .AsReadOnly(); - return Task.CompletedTask; + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); } - public Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) { - var key = (tenant, userKey); + ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(key, out var profile)) - return Task.CompletedTask; - - if (mode == DeleteMode.Hard) - { - _store.Remove(key); - return Task.CompletedTask; - } + var set = userKeys.ToHashSet(); - if (profile.IsDeleted) - return Task.CompletedTask; - - profile.IsDeleted = true; - profile.DeletedAt = deletedAt; + var result = Values() + .Where(x => x.Tenant == tenant) + .Where(x => set.Contains(x.UserKey)) + .Where(x => !x.IsDeleted) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); - return Task.CompletedTask; + return Task.FromResult>(result); } -} +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs deleted file mode 100644 index cbbd3fc0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using System.Collections.Concurrent; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStore : IUserSecurityStateDebugView -{ - private readonly ConcurrentDictionary<(TenantKey, UserKey), InMemoryUserSecurityState> _states = new(); - - public InMemoryUserSecurityState? Get(TenantKey tenant, UserKey userKey) - => _states.TryGetValue((tenant, userKey), out var state) ? state : null; - - public void Set(TenantKey tenant, UserKey userKey, InMemoryUserSecurityState state) - => _states[(tenant, userKey)] = state; - - public void Clear(TenantKey tenant, UserKey userKey) - => _states.TryRemove((tenant, userKey), out _); - - public IUserSecurityState? GetState(TenantKey tenant, UserKey userKey) - => Get(tenant, userKey); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs deleted file mode 100644 index 1b7b8e20..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class AddUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public AddUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs deleted file mode 100644 index 4279660d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class ChangeUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public ChangeUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs deleted file mode 100644 index 954c6fa6..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class ChangeUserStatusCommand : IAccessCommand -{ - private readonly Func _execute; - - public ChangeUserStatusCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs deleted file mode 100644 index 675ebde9..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class CreateUserCommand : IAccessCommand -{ - private readonly Func> _execute; - - public CreateUserCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs deleted file mode 100644 index da4acc96..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DeleteUserCommand : IAccessCommand -{ - private readonly Func _execute; - - public DeleteUserCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs deleted file mode 100644 index 59b9d079..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DeleteUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public DeleteUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs deleted file mode 100644 index 90302ae1..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetMeCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetMeCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs deleted file mode 100644 index d5eb10f2..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserIdentifierCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetUserIdentifierCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs deleted file mode 100644 index a8219862..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserIdentifiersCommand : IAccessCommand> -{ - private readonly Func>> _execute; - - public GetUserIdentifiersCommand(Func>> execute) - { - _execute = execute; - } - - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs deleted file mode 100644 index 82e7fe12..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserProfileCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetUserProfileCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs deleted file mode 100644 index 8a56df8c..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class SetPrimaryUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public SetPrimaryUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs deleted file mode 100644 index 48a7ad89..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UnsetPrimaryUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public UnsetPrimaryUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs deleted file mode 100644 index 1005453d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs deleted file mode 100644 index aa38706a..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserProfileAdminCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserProfileAdminCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs deleted file mode 100644 index b102c86b..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserProfileCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserProfileCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs deleted file mode 100644 index 28602a36..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UserIdentifierExistsCommand : IAccessCommand -{ - private readonly Func> _execute; - - public UserIdentifierExistsCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs deleted file mode 100644 index 186433d6..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class VerifyUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public VerifyUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs index a6c6a944..b1dc66e0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -1,12 +1,9 @@ -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class UserLifecycleQuery +public sealed class UserLifecycleQuery : PageRequest { public bool IncludeDeleted { get; init; } public UserStatus? Status { get; init; } - - public int Skip { get; init; } - public int Take { get; init; } = 50; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs index d0cd9262..7b2808a9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Core.Contracts; -public sealed class UserProfileQuery +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserProfileQuery : PageRequest { public bool IncludeDeleted { get; init; } - - public int Skip { get; init; } - public int Take { get; init; } = 50; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs deleted file mode 100644 index 68f2948c..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Reference; - -public sealed record UserProfileUpdate -{ - 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.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index 5d06adc5..88151a2b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -1,27 +1,162 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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 record UserIdentifier +public sealed class UserIdentifier : IVersionedEntity, ISoftDeletable, IEntitySnapshot { - - public Guid Id { get; set; } - public TenantKey Tenant { get; set; } - + 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; set; } = default!; - - public bool IsPrimary { get; set; } - public bool IsVerified { get; set; } + public string Value { get; private set; } = default!; + public string NormalizedValue { get; private set; } = default!; - public bool IsDeleted { get; set; } + public bool IsPrimary { get; private set; } public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? VerifiedAt { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; set; } + 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 UserIdentifierDto ToDto() + { + return new UserIdentifierDto() + { + 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 index 085a2e71..8ee2470b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -1,21 +1,96 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed record class UserLifecycle +public sealed class UserLifecycle : IVersionedEntity, ISoftDeletable, IEntitySnapshot { - public TenantKey Tenant { get; set; } + private UserLifecycle() { } - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } = default!; + public UserKey UserKey { get; private set; } = default!; - public UserStatus Status { get; set; } = UserStatus.Active; - public Guid SecurityStamp { get; set; } + public UserStatus Status { get; private set; } - public bool IsDeleted { get; set; } + public long SecurityVersion { get; private set; } - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; 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; + } } 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 index 15c9ee22..ff0227b4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -1,32 +1,167 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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 record class UserProfile +public sealed class UserProfile : IVersionedEntity, ISoftDeletable, IEntitySnapshot { - public TenantKey Tenant { get; set; } + private UserProfile() { } - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } - public string? FirstName { get; set; } - public string? LastName { get; set; } - public string? DisplayName { get; set; } + public UserKey UserKey { get; init; } = default!; - public DateOnly? BirthDate { get; set; } - public string? Gender { get; set; } - public string? Bio { get; set; } + public string? FirstName { get; private set; } + public string? LastName { get; private set; } + public string? DisplayName { get; private set; } - public string? Language { get; set; } - public string? TimeZone { get; set; } - public string? Culture { get; set; } + public DateOnly? BirthDate { get; private set; } + public string? Gender { get; private set; } + public string? Bio { get; private set; } - public IReadOnlyDictionary? Metadata { get; set; } + public string? Language { get; private set; } + public string? TimeZone { get; private set; } + public string? Culture { get; private set; } - public bool IsDeleted { get; set; } + public IReadOnlyDictionary? Metadata { get; private set; } public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; set; } + 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( + DateTimeOffset createdAt, + TenantKey tenant, + UserKey userKey, + Guid? id = null, + 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; + } } 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 index 3b67efe7..b48a4ecf 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Users.Contracts; @@ -21,7 +21,44 @@ public UserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFact _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) @@ -31,7 +68,7 @@ public async Task CreateAsync(HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, - action: UAuthActions.Users.Create, + action: UAuthActions.Users.CreateAdmin, resource: "users"); var result = await _users.CreateUserAsync(accessContext, request, ctx.RequestAborted); @@ -145,6 +182,22 @@ public async Task UpdateUserAsync(UserKey userKey, HttpContext ctx) 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; @@ -173,13 +226,15 @@ public async Task GetMyIdentifiersAsync(HttpContext ctx) 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,ctx.RequestAborted); + var result = await _users.GetIdentifiersByUserAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(result); } @@ -190,17 +245,73 @@ public async Task GetUserIdentifiersAsync(UserKey userKey, HttpContext 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, ctx.RequestAborted); + 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; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 19040a2e..2cfabc66 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); return services; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs index 918e00e7..5a061172 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.Extensions.Options; @@ -7,15 +8,18 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public sealed class LoginIdentifierResolver : ILoginIdentifierResolver { private readonly IUserIdentifierStore _store; + private readonly IIdentifierNormalizer _normalizer; private readonly IEnumerable _customResolvers; private readonly UAuthLoginIdentifierOptions _options; public LoginIdentifierResolver( IUserIdentifierStore store, + IIdentifierNormalizer normalizer, IEnumerable customResolvers, IOptions options) { _store = store; + _normalizer = normalizer; _customResolvers = customResolvers; _options = options.Value.LoginIdentifiers; } @@ -28,7 +32,16 @@ public LoginIdentifierResolver( return null; var raw = identifier; - var normalized = Normalize(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) { @@ -37,9 +50,7 @@ public LoginIdentifierResolver( return custom; } - var builtInType = DetectBuiltInType(normalized); - - if (!_options.AllowedBuiltIns.Contains(builtInType)) + if (!_options.AllowedTypes.Contains(builtInType)) { if (_options.EnableCustomResolvers && !_options.CustomResolversFirst) return await TryCustomAsync(tenant, normalized, ct); @@ -112,9 +123,6 @@ public LoginIdentifierResolver( return null; } - private static string Normalize(string identifier) - => identifier.Trim(); - private static UserIdentifierType DetectBuiltInType(string normalized) { if (normalized.Contains('@', StringComparison.Ordinal)) 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..82350491 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs @@ -0,0 +1,29 @@ +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 IUserLifecycleStore _store; + + public UserLifecycleSnapshotProvider(IUserLifecycleStore store) + { + _store = store; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + 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 index 3044076e..f3ccc480 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs @@ -15,7 +15,7 @@ public UserProfileSnapshotProvider(IUserProfileStore store) public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var profile = await _store.GetAsync(tenant, userKey, ct); + var profile = await _store.GetAsync(new UserProfileKey(tenant, userKey), ct); if (profile is null || profile.IsDeleted) return null; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs index e999d2d5..f3ad993f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -10,9 +10,11 @@ public static UserIdentifierDto ToDto(UserIdentifier record) Id = record.Id, Type = record.Type, Value = record.Value, + NormalizedValue = record.NormalizedValue, IsPrimary = record.IsPrimary, IsVerified = record.IsVerified, CreatedAt = record.CreatedAt, - VerifiedAt = record.VerifiedAt + 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 index 5d0a536c..e7a95f69 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -4,10 +4,10 @@ namespace CodeBeam.UltimateAuth.Users.Reference; internal static class UserProfileMapper { - public static UserViewDto ToDto(UserProfile profile) + public static UserView ToDto(UserProfile profile) => new() { - UserKey = profile.UserKey.ToString(), + UserKey = profile.UserKey, FirstName = profile.FirstName, LastName = profile.LastName, DisplayName = profile.DisplayName, @@ -15,21 +15,9 @@ public static UserViewDto ToDto(UserProfile profile) BirthDate = profile.BirthDate, CreatedAt = profile.CreatedAt, Gender = profile.Gender, + Culture = profile.Culture, + Language = profile.Language, + TimeZone = profile.TimeZone, Metadata = profile.Metadata }; - - public static UserProfileUpdate ToUpdate(UpdateProfileRequest request) - => new() - { - FirstName = request.FirstName, - LastName = request.LastName, - DisplayName = request.DisplayName, - BirthDate = request.BirthDate, - Gender = request.Gender, - Bio = request.Bio, - Language = request.Language, - TimeZone = request.TimeZone, - Culture = request.Culture, - Metadata = request.Metadata - }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index b01bc97f..0bdb2192 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -5,20 +5,21 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserApplicationService { - Task GetMeAsync(AccessContext context, CancellationToken ct = default); - Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); + 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, 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, 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); @@ -32,5 +33,6 @@ public interface IUserApplicationService 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 index 08089c61..fbc7dce2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -16,8 +16,12 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserLifecycleStore _lifecycleStore; private readonly IUserProfileStore _profileStore; private readonly IUserIdentifierStore _identifierStore; + private readonly IUserCreateValidator _userCreateValidator; + private readonly IIdentifierValidator _identifierValidator; private readonly IEnumerable _integrations; - private readonly UAuthUserIdentifierOptions _identifierOptions; + private readonly IIdentifierNormalizer _identifierNormalizer; + private readonly ISessionStoreFactory _sessionStoreFactory; + private readonly UAuthServerOptions _options; private readonly IClock _clock; public UserApplicationService( @@ -25,7 +29,11 @@ public UserApplicationService( IUserLifecycleStore lifecycleStore, IUserProfileStore profileStore, IUserIdentifierStore identifierStore, + IUserCreateValidator userCreateValidator, + IIdentifierValidator identifierValidator, IEnumerable integrations, + IIdentifierNormalizer identifierNormalizer, + ISessionStoreFactory sessionStoreFactory, IOptions options, IClock clock) { @@ -33,95 +41,95 @@ public UserApplicationService( _lifecycleStore = lifecycleStore; _profileStore = profileStore; _identifierStore = identifierStore; + _userCreateValidator = userCreateValidator; + _identifierValidator = identifierValidator; _integrations = integrations; - _identifierOptions = options.Value.UserIdentifiers; + _identifierNormalizer = identifierNormalizer; + _sessionStoreFactory = sessionStoreFactory; + _options = options.Value; _clock = clock; } - public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) - { - var command = new GetMeCommand(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 GetUserProfileCommand(async innerCt => - { - // Target user MUST exist in context - var targetUserKey = context.GetTargetUserKey(); - - return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); - - }); - - return await _accessOrchestrator.ExecuteAsync(context, command, ct); - } + #region User Lifecycle public async Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) { - var command = new CreateUserCommand(async innerCt => + 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(); - if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is null) + await _lifecycleStore.AddAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); + + await _profileStore.AddAsync( + UserProfile.Create( + now, + context.ResourceTenant, + userKey, + 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); + + if (!string.IsNullOrWhiteSpace(request.UserName)) { - return UserCreateResult.Failed("primary_identifier_type_required"); + 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); } - await _lifecycleStore.CreateAsync(context.ResourceTenant, - new UserLifecycle - { - UserKey = userKey, - Status = UserStatus.Active, - CreatedAt = now - }, - innerCt); - - await _profileStore.CreateAsync(context.ResourceTenant, - new UserProfile - { - UserKey = userKey, - FirstName = request.FirstName, - LastName = request.LastName, - DisplayName = request.DisplayName, - BirthDate = request.BirthDate, - Gender = request.Gender, - Bio = request.Bio, - Language = request.Language, - TimeZone = request.TimeZone, - Culture = request.Culture, - Metadata = request.Metadata, - CreatedAt = now - }, - 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.PrimaryIdentifierValue) && request.PrimaryIdentifierType is not null) + if (!string.IsNullOrWhiteSpace(request.Phone)) { - await _identifierStore.CreateAsync(context.ResourceTenant, - new UserIdentifier - { - UserKey = userKey, - Type = request.PrimaryIdentifierType.Value, - Value = request.PrimaryIdentifierValue, - IsPrimary = true, - IsVerified = request.PrimaryIdentifierVerified, - CreatedAt = now, - VerifiedAt = request.PrimaryIdentifierVerified ? now : null - }, - innerCt); + 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); } @@ -133,59 +141,189 @@ await _identifierStore.CreateAsync(context.ResourceTenant, public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) { - var command = new ChangeUserStatusCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var newStatus = request switch { - ChangeUserStatusSelfRequest r => r.NewStatus, + ChangeUserStatusSelfRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), ChangeUserStatusAdminRequest r => r.NewStatus, _ => throw new InvalidOperationException("invalid_request") }; var targetUserKey = context.GetTargetUserKey(); - var current = await _lifecycleStore.GetAsync(context.ResourceTenant, targetUserKey, innerCt); + var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); + var current = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); + var now = _clock.UtcNow; if (current is null) - throw new InvalidOperationException("user_not_found"); + throw new UAuthNotFoundException("user_not_found"); if (context.IsSelfAction && !IsSelfTransitionAllowed(current.Status, newStatus)) - throw new InvalidOperationException("self_transition_not_allowed"); + throw new UAuthConflictException("self_transition_not_allowed"); if (!context.IsSelfAction) { - if (newStatus is UserStatus.SelfSuspended or UserStatus.Deactivated) - throw new InvalidOperationException("admin_cannot_set_self_status"); + if (newStatus is UserStatus.SelfSuspended) + throw new UAuthConflictException("admin_cannot_set_self_status"); } - - await _lifecycleStore.ChangeStatusAsync(context.ResourceTenant, targetUserKey, newStatus, _clock.UtcNow, innerCt); + var newEntity = current.ChangeStatus(now, newStatus); + await _lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) + 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 lifecycle = await _lifecycleStore.GetAsync(lifecycleKey, innerCt); + + if (lifecycle is null) + throw new UAuthNotFoundException(); + + 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(context.ResourceTenant, 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(context.ResourceTenant, userKey, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) { - var command = new UpdateUserProfileCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var targetUserKey = context.GetTargetUserKey(); - var update = UserProfileMapper.ToUpdate(request); + var now = _clock.UtcNow; + var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); + + var lifecycle = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); + + if (lifecycle is null) + throw new UAuthNotFoundException(); + + 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(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + + if (profile is not null) + { + await _profileStore.DeleteAsync(profileKey, profile.Version, request.Mode, now, innerCt); + } - await _profileStore.UpdateAsync(context.ResourceTenant, targetUserKey, update, _clock.UtcNow, 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 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, CancellationToken ct = default) + public async Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default) { - var command = new GetUserIdentifiersCommand(async innerCt => + var command = new AccessCommand>(async innerCt => { var targetUserKey = context.GetTargetUserKey(); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, targetUserKey, innerCt); - return identifiers.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); + query ??= new UserIdentifierQuery(); + query.UserKey = targetUserKey; + + var result = await _identifierStore.QueryAsync(context.ResourceTenant, 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); @@ -193,20 +331,31 @@ public async Task> GetIdentifiersByUserAsync(Ac public async Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) { - var command = new GetUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { - var identifier = await _identifierStore.GetAsync(context.ResourceTenant, type, value, innerCt); + var normalized = _identifierNormalizer.Normalize(type, value); + if (!normalized.IsValid) + return null; + + var identifier = await _identifierStore.GetAsync(context.ResourceTenant, 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, CancellationToken ct = default) + public async Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, IdentifierExistenceScope scope = IdentifierExistenceScope.TenantPrimaryOnly, CancellationToken ct = default) { - var command = new UserIdentifierExistsCommand(async innerCt => + var command = new AccessCommand(async innerCt => { - return await _identifierStore.ExistsAsync(context.ResourceTenant, type, value, innerCt); + var normalized = _identifierNormalizer.Normalize(type, value); + if (!normalized.IsValid) + return false; + + UserKey? userKey = scope == IdentifierExistenceScope.WithinUser ? context.GetTargetUserKey() : null; + + var result = await _identifierStore.ExistsAsync(new IdentifierExistenceQuery(context.ResourceTenant, type, normalized.Normalized, scope, userKey), innerCt); + return result.Exists; }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -214,32 +363,69 @@ public async Task UserIdentifierExistsAsync(AccessContext context, UserIde public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default) { - var command = new AddUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { + var validationDto = new UserIdentifierDto() { 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 existing = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); - EnsureOverrideAllowed(context); EnsureMultipleIdentifierAllowed(request.Type, existing); + var userScopeResult = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery(context.ResourceTenant, 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( + context.ResourceTenant, + 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 ); + EnsureVerificationRequirements(request.Type, isVerified: false); } - await _identifierStore.CreateAsync(context.ResourceTenant, - new UserIdentifier - { - UserKey = userKey, - Type = request.Type, - Value = request.Value, - IsPrimary = request.IsPrimary, - IsVerified = false, - CreatedAt = _clock.UtcNow - }, - innerCt); + 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); @@ -247,24 +433,73 @@ await _identifierStore.CreateAsync(context.ResourceTenant, public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default) { - var command = new UpdateUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { + EnsureOverrideAllowed(context); + var identifier = await _identifierStore.GetByIdAsync(request.Id, innerCt); if (identifier is null || identifier.IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); - EnsureOverrideAllowed(context); - - if (identifier.Type == UserIdentifierType.Username && !_identifierOptions.AllowUsernameChange) + if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.AllowUsernameChange) { throw new UAuthIdentifierValidationException("username_change_not_allowed"); } - if (string.Equals(identifier.Value, request.NewValue, StringComparison.Ordinal)) + 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"); - await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, _clock.UtcNow, innerCt); + var withinUserResult = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Tenant, + 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.Tenant, + 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); @@ -272,7 +507,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default) { - var command = new SetPrimaryUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -280,9 +515,20 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); + if (identifier.IsPrimary) + throw new UAuthIdentifierValidationException("identifier_already_primary"); + EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); - await _identifierStore.SetPrimaryAsync(request.IdentifierId, innerCt); + var result = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery(identifier.Tenant, 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); @@ -290,7 +536,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default) { - var command = new UnsetPrimaryUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -299,20 +545,27 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (!identifier.IsPrimary) - throw new UAuthIdentifierValidationException("identifier_not_primary"); + throw new UAuthIdentifierValidationException("identifier_already_not_primary"); - var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); + var userIdentifiers = + await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); - var otherLoginIdentifiers = identifiers - .Where(i => !i.IsDeleted && - IsLoginIdentifier(i.Type) && - i.Id != identifier.Id) + var activeLoginPrimaries = userIdentifiers + .Where(i => + !i.IsDeleted && + i.IsPrimary && + _options.LoginIdentifiers.AllowedTypes.Contains(i.Type)) .ToList(); - if (otherLoginIdentifiers.Count == 0) - throw new UAuthIdentifierConflictException("cannot_unset_last_primary_login_identifier"); + if (activeLoginPrimaries.Count == 1 && + activeLoginPrimaries[0].Id == identifier.Id) + { + throw new UAuthIdentifierConflictException("cannot_unset_last_login_identifier"); + } - await _identifierStore.UnsetPrimaryAsync(request.IdentifierId, innerCt); + var expectedVersion = identifier.Version; + identifier.UnsetPrimary(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -320,10 +573,17 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default) { - var command = new VerifyUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); - await _identifierStore.MarkVerifiedAsync(request.IdentifierId, _clock.UtcNow, innerCt); + + var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, 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); @@ -331,7 +591,7 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default) { - var command = new DeleteUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -345,8 +605,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde if (identifier.IsPrimary) throw new UAuthIdentifierValidationException("cannot_delete_primary_identifier"); - if (_identifierOptions.RequireUsernameIdentifier && - identifier.Type == UserIdentifierType.Username) + if (_options.Identifiers.RequireUsernameIdentifier && identifier.Type == UserIdentifierType.Username) { var activeUsernames = identifiers .Where(i => !i.IsDeleted && i.Type == UserIdentifierType.Username) @@ -357,9 +616,19 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde } if (IsLoginIdentifier(identifier.Type) && loginIdentifiers.Count == 1) - throw new UAuthIdentifierConflictException("cannot_delete_last_login_identifier"); + throw new UAuthIdentifierConflictException("cannot_delete_last_login_identifier"); + + var expectedVersion = identifier.Version; - await _identifierStore.DeleteAsync(request.IdentifierId, request.Mode, _clock.UtcNow, innerCt); + 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); @@ -367,32 +636,19 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde #endregion - public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) - { - var command = new DeleteUserCommand(async innerCt => - { - var targetUserKey = context.GetTargetUserKey(); - var now = _clock.UtcNow; - - await _lifecycleStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - await _identifierStore.DeleteByUserAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - await _profileStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - - foreach (var integration in _integrations) - { - await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt); - } - }); - await _accessOrchestrator.ExecuteAsync(context, command, ct); - } + #region Helpers - private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) { - var profile = await _profileStore.GetAsync(tenant, userKey, ct); + 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 InvalidOperationException("user_profile_not_found"); + throw new UAuthNotFoundException("user_profile_not_found"); var identifiers = await _identifierStore.GetByUserAsync(tenant, userKey, ct); @@ -408,7 +664,8 @@ private async Task BuildUserViewAsync(TenantKey tenant, UserKey use PrimaryEmail = primaryEmail?.Value, PrimaryPhone = primaryPhone?.Value, EmailVerified = primaryEmail?.IsVerified ?? false, - PhoneVerified = primaryPhone?.IsVerified ?? false + PhoneVerified = primaryPhone?.IsVerified ?? false, + Status = lifecycle.Status }; } @@ -419,36 +676,36 @@ private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyL if (!hasSameType) return; - if (type == UserIdentifierType.Username && !_identifierOptions.AllowMultipleUsernames) - throw new InvalidOperationException("multiple_usernames_not_allowed"); + if (type == UserIdentifierType.Username && !_options.Identifiers.AllowMultipleUsernames) + throw new UAuthValidationException("multiple_usernames_not_allowed"); - if (type == UserIdentifierType.Email && !_identifierOptions.AllowMultipleEmail) - throw new InvalidOperationException("multiple_emails_not_allowed"); + if (type == UserIdentifierType.Email && !_options.Identifiers.AllowMultipleEmail) + throw new UAuthValidationException("multiple_emails_not_allowed"); - if (type == UserIdentifierType.Phone && !_identifierOptions.AllowMultiplePhone) - throw new InvalidOperationException("multiple_phones_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 && _identifierOptions.RequireEmailVerification && !isVerified) + if (type == UserIdentifierType.Email && _options.Identifiers.RequireEmailVerification && !isVerified) { - throw new InvalidOperationException("email_verification_required"); + throw new UAuthValidationException("email_verification_required"); } - if (type == UserIdentifierType.Phone && _identifierOptions.RequirePhoneVerification && !isVerified) + if (type == UserIdentifierType.Phone && _options.Identifiers.RequirePhoneVerification && !isVerified) { - throw new InvalidOperationException("phone_verification_required"); + throw new UAuthValidationException("phone_verification_required"); } } private void EnsureOverrideAllowed(AccessContext context) { - if (context.IsSelfAction && !_identifierOptions.AllowUserOverride) - throw new InvalidOperationException("user_override_not_allowed"); + if (context.IsSelfAction && !_options.Identifiers.AllowUserOverride) + throw new UAuthConflictException("user_override_not_allowed"); - if (!context.IsSelfAction && !_identifierOptions.AllowAdminOverride) - throw new InvalidOperationException("admin_override_not_allowed"); + if (!context.IsSelfAction && !_options.Identifiers.AllowAdminOverride) + throw new UAuthConflictException("admin_override_not_allowed"); } private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) @@ -456,7 +713,6 @@ private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) { (UserStatus.Active, UserStatus.SelfSuspended) => true, (UserStatus.SelfSuspended, UserStatus.Active) => true, - (UserStatus.Active or UserStatus.SelfSuspended, UserStatus.Deactivated) => true, _ => false }; @@ -466,4 +722,116 @@ 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 lifecycleResult = await _lifecycleStore.QueryAsync(context.ResourceTenant, 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 profiles = await _profileStore.GetByUsersAsync(context.ResourceTenant, userKeys, innerCt); + var identifiers = await _identifierStore.GetByUsersAsync(context.ResourceTenant, 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 index 7b7aaa2a..407b03a3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -1,30 +1,21 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public interface IUserIdentifierStore +public interface IUserIdentifierStore : IVersionedStore { - Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); + Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default); Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); - Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default); - - Task UpdateValueAsync(Guid id, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default); - - Task SetPrimaryAsync(Guid id, CancellationToken ct = default); - - Task UnsetPrimaryAsync(Guid id, CancellationToken ct = default); - - Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default); + Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs index 38b0c42f..c930be38 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -1,23 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public interface IUserLifecycleStore +public interface IUserLifecycleStore : IVersionedStore { - Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default); - - Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default); - - Task ChangeStatusAsync(TenantKey tenant, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task ChangeSecurityStampAsync(TenantKey tenant, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index 8c34959d..dbedae45 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,20 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +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 interface IUserProfileStore +public interface IUserProfileStore : IVersionedStore { - Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default); - - Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default); - - Task UpdateAsync(TenantKey tenant, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs index b864f6e8..4a4e16fb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -16,7 +16,8 @@ public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var lifecycle = await _lifecycleStore.GetAsync(tenant, userKey, ct); + var userLifecycleKey = new UserLifecycleKey(tenant, userKey); + var lifecycle = await _lifecycleStore.GetAsync(userLifecycleKey, ct); if (lifecycle is null) return null; @@ -25,6 +26,7 @@ public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) { 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/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/IUserSecurityEvents.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs index 4c1e0cbd..01dea3d1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs @@ -1,8 +1,10 @@ -namespace CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Core.Domain; -public interface IUserSecurityEvents +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityEvents { - Task OnUserActivatedAsync(TUserId userId); - Task OnUserDeactivatedAsync(TUserId userId); - Task OnSecurityInvalidatedAsync(TUserId userId); + Task OnUserActivatedAsync(UserKey userKey); + Task OnUserDeactivatedAsync(UserKey userKey); + Task OnSecurityInvalidatedAsync(UserKey userKey); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs deleted file mode 100644 index 74fa08d0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityState -{ - long SecurityVersion { get; } - int FailedLoginAttempts { get; } - DateTimeOffset? LockedUntil { get; } - bool RequiresReauthentication { get; } - DateTimeOffset? LastFailedAt { get; } - - bool IsLocked => LockedUntil.HasValue && LockedUntil > DateTimeOffset.UtcNow; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs deleted file mode 100644 index 6c97bc7e..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -internal interface IUserSecurityStateDebugView -{ - IUserSecurityState? GetState(TenantKey tenant, UserKey userKey); - void Clear(TenantKey tenant, UserKey userKey); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs deleted file mode 100644 index b22955f0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityStateProvider -{ - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs deleted file mode 100644 index 20cb713b..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityStateWriter -{ - Task RecordFailedLoginAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default); - Task ResetFailuresAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task LockUntilAsync(TenantKey tenant, UserKey userKey, DateTimeOffset lockedUntil, CancellationToken ct = default); -} 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/Client/AuthStateSnapshotFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs index 01d86911..aca9fa48 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs @@ -16,6 +16,7 @@ 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 @@ -23,7 +24,7 @@ public async Task CreateAsync_should_return_snapshot_when_valid() UserName = "admin" }); - var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object); + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); var validation = SessionValidationResult.Active( TenantKey.FromInternal("__single__"), @@ -46,8 +47,9 @@ 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); + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); var validation = SessionValidationResult.Invalid(SessionState.NotFound); var snapshot = await factory.CreateAsync(validation); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs index f8302b0d..d87c40f2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs @@ -1,12 +1,13 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; -using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using Moq; -using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -15,6 +16,7 @@ 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(); @@ -27,7 +29,7 @@ public async Task StartAsync_should_not_start_when_auto_refresh_disabled() } }); - var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics); + var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics, clock); await coordinator.StartAsync(); Assert.False(diagnostics.IsRunning); @@ -36,6 +38,8 @@ public async Task StartAsync_should_not_start_when_auto_refresh_disabled() [Fact] public async Task ReauthRequired_should_raise_event() { + var clock = new TestClock(); + var client = new Mock(); var nav = new Mock(); var diagnostics = new UAuthClientDiagnostics(); @@ -51,7 +55,7 @@ public async Task ReauthRequired_should_raise_event() AutoRefresh = new UAuthClientAutoRefreshOptions { Enabled = true, - Interval = TimeSpan.FromMilliseconds(10) + Interval = TimeSpan.FromSeconds(5) }, Reauth = new UAuthClientReauthOptions { @@ -59,12 +63,11 @@ public async Task ReauthRequired_should_raise_event() } }); - var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics); + var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics, clock); var triggered = false; coordinator.ReauthRequired += () => triggered = true; - await coordinator.StartAsync(); - await Task.Delay(50); + await coordinator.TickAsync(); Assert.True(triggered); Assert.True(diagnostics.IsTerminated); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs index 2fd7b759..1ee40234 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs @@ -1,5 +1,6 @@ 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; @@ -37,10 +38,11 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta 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, clock.Object); + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); await manager.EnsureAsync(); await manager.EnsureAsync(); @@ -49,29 +51,39 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta } [Fact] - public async Task EnsureAsync_force_should_always_validate() + public async Task EnsureAsync_should_deduplicate_concurrent_calls() { - var client = new Mock(); - var clock = new Mock(); + var flows = new Mock(); - client.Setup(x => x.Flows.ValidateAsync()) - .ReturnsAsync(new AuthValidationResult + flows.Setup(x => x.ValidateAsync()) + .Returns(async () => { - State = SessionState.Invalid + await Task.Delay(50); + return new AuthValidationResult { State = SessionState.Invalid }; }); - var manager = new UAuthStateManager(client.Object, clock.Object); + var client = new Mock(); + client.SetupGet(x => x.Flows).Returns(flows.Object); - await manager.EnsureAsync(force: true); - await manager.EnsureAsync(force: true); + 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) + ); - client.Verify(x => x.Flows.ValidateAsync(), Times.Exactly(2)); + 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()) @@ -80,10 +92,8 @@ public async Task EnsureAsync_invalid_should_clear_state() State = SessionState.Invalid }); - var manager = new UAuthStateManager(client.Object, clock.Object); - + 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/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 6e11feeb..f0adf68b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index 952588b5..3e967fce 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -36,7 +36,7 @@ public async Task Invalid_When_Token_Not_Found() Tenant = TenantKey.Single, RefreshToken = "non-existing", Now = DateTimeOffset.UtcNow, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); @@ -73,7 +73,7 @@ public async Task Reuse_Detected_When_Token_is_Revoked() Tenant = TenantKey.Single, RefreshToken = rawToken, Now = now, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); @@ -106,7 +106,7 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() RefreshToken = "hash-2", ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), Now = now, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs index 8c010928..2219f194 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -20,8 +21,12 @@ public void New_chain_has_expected_initial_state() SessionRootId.New(), tenant: TenantKey.Single, userKey: UserKey.FromString("user-1"), - securityVersion: 0, - ClaimsSnapshot.Empty); + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); Assert.Equal(0, chain.RotationCount); Assert.Null(chain.ActiveSessionId); @@ -34,13 +39,17 @@ public void Rotating_chain_sets_active_session_and_increments_rotation() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + 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); + var rotated = chain.RotateSession(sessionId, DateTimeOffset.UtcNow); Assert.Equal(1, rotated.RotationCount); Assert.Equal(sessionId, rotated.ActiveSessionId); @@ -53,13 +62,17 @@ public void Multiple_rotations_increment_rotation_count() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); - var first = chain.RotateSession(CreateSessionId("s1")); - var second = first.RotateSession(CreateSessionId("s2")); + 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); @@ -73,13 +86,17 @@ public void Revoked_chain_does_not_rotate() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + 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")); + var rotated = revoked.RotateSession(CreateSessionId("s2"), DateTimeOffset.UtcNow); Assert.Same(revoked, rotated); Assert.True(rotated.IsRevoked); @@ -93,10 +110,14 @@ public void Revoking_chain_sets_revocation_fields() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var revoked = chain.Revoke(now); @@ -112,10 +133,14 @@ public void Revoking_already_revoked_chain_is_idempotent() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + 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)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index 6a858508..e988dc1d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -21,7 +21,7 @@ public void Revoke_marks_session_as_revoked() chainId: SessionChainId.New(), now, now.AddMinutes(10), - DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + 0, ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -45,7 +45,7 @@ public void Revoking_twice_returns_same_instance() SessionChainId.New(), now, now.AddMinutes(10), - DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + 0, ClaimsSnapshot.Empty, SessionMetadata.Empty); 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..0413196d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs @@ -0,0 +1,133 @@ +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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + oldLogin.IsSuccess.Should().BeFalse(); + + var newLogin = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + 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..431b8579 --- /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 BeginCredentialResetRequest + { + 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 BeginCredentialResetRequest + { + 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 BeginCredentialResetRequest + { + 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 BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + var result = await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + 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 BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + 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 BeginCredentialResetRequest + { + 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 CompleteCredentialResetRequest + { + 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 CompleteCredentialResetRequest + { + 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 BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + 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 BeginCredentialResetRequest + { + 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 CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index f1572c2a..1d6b2e0e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -2,6 +2,7 @@ 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; @@ -50,11 +51,51 @@ 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 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 LogoutOtherDevicesAdminAsync(LogoutOtherDevicesAdminRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutOtherDevicesAdminAsync(UserKey userKey, LogoutOtherDevicesAdminRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutOtherDevicesSelfAsync() + { + throw new NotImplementedException(); + } + public Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string? returnUrl = null) { throw new NotImplementedException(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs index 67a4b94d..46de8a34 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs @@ -25,9 +25,9 @@ public static AuthFlowContext LoginSuccess(ReturnUrlInfo? returnUrlInfo = null, originalOptions: TestServerOptions.Default(), effectiveOptions: TestServerOptions.Effective(), response: new EffectiveAuthResponse( - sessionIdDelivery: CredentialResponseOptions.Disabled(CredentialKind.Session), - accessTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.AccessToken), - refreshTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.RefreshToken), + sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.RefreshToken), redirect: redirect ?? EffectiveRedirectResponse.Disabled ), primaryTokenKind: PrimaryTokenKind.Session, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs index 137f63b9..def622e2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -12,6 +13,7 @@ public static AccessContext WithAction(string action) actorTenant: TenantKey.Single, isAuthenticated: false, isSystemActor: false, + actorChainId: null, resource: "test", targetUserKey: null, resourceTenant: TenantKey.Single, @@ -19,4 +21,22 @@ public static AccessContext WithAction(string 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/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 368fa3bb..26e6d480 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -1,15 +1,19 @@ -using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authentication.InMemory; +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; 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.InMemory.Extensions; +using CodeBeam.UltimateAuth.Credentials.Reference; 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.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; @@ -24,9 +28,11 @@ 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(); @@ -43,17 +49,22 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddUltimateAuthCredentialsInMemory(); services.AddUltimateAuthInMemorySessions(); services.AddUltimateAuthInMemoryTokens(); + services.AddUltimateAuthInMemoryAuthenticationSecurity(); services.AddUltimateAuthAuthorizationInMemory(); - services.AddUltimateAuthAuthorizationReference(); services.AddUltimateAuthUsersReference(); + services.AddUltimateAuthAuthorizationReference(); + services.AddUltimateAuthCredentialsReference(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(sp => + sp.GetRequiredService()); var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); services.AddSingleton(configuration); - + services.AddSingleton(Clock); Services = services.BuildServiceProvider(); Services.GetRequiredService().RunAsync(null).GetAwaiter().GetResult(); @@ -67,4 +78,31 @@ 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 + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + } } 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 index 7265c154..87daa2e2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs @@ -4,5 +4,5 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal static class TestDevice { - public static DeviceContext Default() => DeviceContext.FromDeviceId(DeviceId.Create("test-device-000-000-000-000-01")); + public static DeviceContext Default() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-01"), null, null, null, null, null); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs index ef3d3deb..1e478c79 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; @@ -13,7 +13,8 @@ public static HttpContext Create(TenantKey? tenant = null, UAuthClientProfile cl 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"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs index bb7e05d6..b64b32db 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -1,26 +1,60 @@ -using CodeBeam.UltimateAuth.Policies; +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.profile.get.self", false)] - [InlineData("users.profile.get", false)] - public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) + [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 policy = new RequireAdminPolicy(); - Assert.Equal(expected, policy.AppliesTo(context)); + var result = _policy.AppliesTo(context); + Assert.False(result); } [Fact] - public void RequireAdminPolicy_DoesNotMatch_Substrings() + public void AppliesTo_DoesNotMatch_Substrings() { var context = TestAccessContext.WithAction("users.profile.get.administrator"); - var policy = new RequireAdminPolicy(); - Assert.False(policy.AppliesTo(context)); + 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/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index 604465d5..2f6aabb7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -1,13 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +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.Tests.Unit.Helpers; -using CodeBeam.UltimateAuth.Users.InMemory; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using System.Security; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -26,7 +25,6 @@ public async Task Successful_login_should_return_success_result() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), }); result.IsSuccess.Should().BeTrue(); @@ -45,7 +43,6 @@ public async Task Successful_login_should_create_session() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), }); result.SessionId.Should().NotBeNull(); @@ -68,14 +65,11 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - - var state = store.GetState(TenantKey.Single, TestUsers.User); - - state!.FailedLoginAttempts.Should().Be(1); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state?.FailedAttempts.Should().Be(1); } [Fact] @@ -95,7 +89,6 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); await orchestrator.LoginAsync(flow, @@ -104,13 +97,11 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "user", // valid password - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - - var state = store.GetState(TenantKey.Single, TestUsers.User); - state.Should().BeNull(); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state?.FailedAttempts.Should().Be(0); } [Fact] @@ -126,7 +117,6 @@ public async Task Invalid_password_should_fail_login() Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -145,7 +135,6 @@ public async Task Non_existent_user_should_fail_login_gracefully() Tenant = TenantKey.Single, Identifier = "ghost", Secret = "whatever", - Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -168,13 +157,12 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); - state!.IsLocked.Should().BeTrue(); + state!.IsLocked(DateTimeOffset.UtcNow).Should().BeTrue(); } [Fact] @@ -188,24 +176,20 @@ public async Task Locked_user_should_not_login_even_with_correct_password() var orchestrator = runtime.GetLoginOrchestrator(); var flow = await runtime.CreateLoginFlowAsync(); - // lock await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - // try again with correct password var result = await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -228,24 +212,20 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state1 = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state1 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", - Secret = "wrong", - Device = TestDevice.Default(), }); - var state2 = store.GetState(TenantKey.Single, TestUsers.User); - - state2!.FailedLoginAttempts.Should().Be(state1!.FailedLoginAttempts); + var state2 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state2?.FailedAttempts.Should().Be(state1!.FailedAttempts); } [Fact] @@ -267,34 +247,14 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); } - var store = runtime.Services.GetRequiredService(); - var state = store.GetState(TenantKey.Single, TestUsers.User); - - state!.IsLocked.Should().BeFalse(); - state.FailedLoginAttempts.Should().Be(5); - } - - [Fact] - public async Task Invalid_device_id_should_throw_security_exception() - { - var runtime = new TestAuthRuntime(); - var orchestrator = runtime.GetLoginOrchestrator(); - var flow = await runtime.CreateLoginFlowAsync(); - - Func act = () => orchestrator.LoginAsync(flow, - new LoginRequest - { - Tenant = TenantKey.Single, - Identifier = "user", - Secret = "user", - Device = DeviceContext.FromDeviceId(DeviceId.Create("x")), // too short - }); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); - await act.Should().ThrowAsync(); + state?.IsLocked(DateTimeOffset.UtcNow).Should().BeFalse(); + state?.FailedAttempts.Should().Be(5); } [Fact] @@ -315,11 +275,10 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state1 = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state1 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); var lockedUntil = state1!.LockedUntil; @@ -329,11 +288,10 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var state2 = store.GetState(TenantKey.Single, TestUsers.User); - state2!.LockedUntil.Should().Be(lockedUntil); + var state2 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state2?.LockedUntil.Should().Be(lockedUntil); } [Fact] @@ -358,7 +316,6 @@ public async Task Login_success_should_trigger_UserLoggedIn_event() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() }); captured.Should().NotBeNull(); @@ -387,7 +344,6 @@ public async Task Login_success_should_trigger_OnAnyEvent() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() }); count.Should().BeGreaterThan(0); @@ -409,7 +365,6 @@ public async Task Event_handler_exception_should_not_break_login_flow() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() }); result.IsSuccess.Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs index 13180acb..cd72bded 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Constants; 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; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index f25dfb04..90b20a6f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -243,8 +243,8 @@ public void UserIdentifiers_both_admin_and_user_override_disabled_should_fail() services.AddOptions() .Configure(o => { - o.UserIdentifiers.AllowAdminOverride = false; - o.UserIdentifiers.AllowUserOverride = false; + o.Identifiers.AllowAdminOverride = false; + o.Identifiers.AllowUserOverride = false; }); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); @@ -268,14 +268,14 @@ public void UserIdentifiers_at_least_one_override_enabled_should_pass() services.AddOptions() .Configure(o => { - o.UserIdentifiers.AllowAdminOverride = true; - o.UserIdentifiers.AllowUserOverride = false; + o.Identifiers.AllowAdminOverride = true; + o.Identifiers.AllowUserOverride = false; }); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; - options.UserIdentifiers.AllowAdminOverride.Should().BeTrue(); + options.Identifiers.AllowAdminOverride.Should().BeTrue(); } [Fact] 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..dec7a17f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs @@ -0,0 +1,136 @@ +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 + { + Tenant = TenantKey.Single, + 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 + { + Tenant = TenantKey.Single, + 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 + { + Tenant = TenantKey.Single, + 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..a66d3dd7 --- /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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(tenant, 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..277d6922 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -0,0 +1,381 @@ +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 + { + Tenant = TenantKey.Single, + Identifier = "+905551111111", + Secret = "user", + //Device = TestDevice.Default() + }); + + 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 + { + IdentifierId = 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(); + //} +} + From 102765161444ce2cade34ea47bf08b8576693ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 14 Mar 2026 02:35:54 +0300 Subject: [PATCH 34/50] Revise README for development status and release info Updated the README to reflect the current development status and upcoming release plans. --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 84af7abe..91dbb94e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ ![UltimateAuth Banner](https://github.com/user-attachments/assets/4204666e-b57a-4cb5-8846-dc7e4f16bfe9) -⚠️ UltimateAuth is under active development. Core architecture and public APIs are now in place. The first release (v 0.0.1) is expected within days. +⚠️ **UltimateAuth is under active development.** + +The core architecture and public APIs are now implemented and validated through the sample application. + +We are currently polishing the developer experience, reviewing the public client API surface, and preparing the EF Core integration packages. + +The first preview release (**v 0.1.0-preview**) is planned within the next week. + ![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) From 5207c2b2387ea6a95d325a4f80295aae4a8dbb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:15:47 +0300 Subject: [PATCH 35/50] Improve Blazor WASM Sample (#22) * Improve Blazor WASM Sample * WASM Sample New Design Implementation * Added Device Id Support For PKCE Login * Completed WASM Sample --- .../Brand/UAuthLogo.razor | 19 + .../Brand/UAuthLogo.razor.cs | 54 ++ .../Brand/UAuthLogoVariant.cs | 7 + ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 6 +- .../Components/App.razor | 4 +- .../Components/Pages/Home.razor | 24 +- .../Components/Pages/Home.razor.cs | 197 +++--- .../Components/Pages/NotAuthorized.razor | 27 + .../Components/Pages/NotAuthorized.razor.cs | 15 + .../Components/Routes.razor | 71 ++- .../Controllers/HubLoginController.cs | 2 +- .../Infrastructure/DarkModeManager.cs | 45 ++ .../Program.cs | 51 +- .../wwwroot/UltimateAuth-Logo.png | Bin 0 -> 14776 bytes .../wwwroot/app.css | 94 ++- .../wwwroot/favicon.png | Bin 1148 -> 0 bytes .../Brand/UAuthLogo.razor | 24 +- .../Dialogs/AccountStatusDialog.razor | 72 +-- .../Dialogs/AccountStatusDialog.razor.cs | 77 +++ .../Components/Dialogs/CreateUserDialog.razor | 53 +- .../Dialogs/CreateUserDialog.razor.cs | 55 ++ .../Components/Dialogs/CredentialDialog.razor | 83 --- .../Dialogs/CredentialDialog.razor.cs | 92 +++ .../Components/Dialogs/IdentifierDialog.razor | 304 +--------- .../Dialogs/IdentifierDialog.razor.cs | 309 ++++++++++ .../Components/Dialogs/PermissionDialog.razor | 114 ---- .../Dialogs/PermissionDialog.razor.cs | 119 ++++ .../Components/Dialogs/ProfileDialog.razor | 107 +--- .../Components/Dialogs/ProfileDialog.razor.cs | 114 ++++ .../Components/Dialogs/ResetDialog.razor | 39 +- .../Components/Dialogs/ResetDialog.razor.cs | 42 ++ .../Components/Dialogs/RoleDialog.razor | 157 ----- .../Components/Dialogs/RoleDialog.razor.cs | 163 +++++ .../Components/Dialogs/SessionDialog.razor | 281 +-------- .../Components/Dialogs/SessionDialog.razor.cs | 284 +++++++++ .../Components/Dialogs/UserDetailDialog.razor | 90 --- .../Dialogs/UserDetailDialog.razor.cs | 100 +++ .../Components/Dialogs/UserRoleDialog.razor | 105 ---- .../Dialogs/UserRoleDialog.razor.cs | 112 ++++ .../Components/Dialogs/UsersDialog.razor | 166 ----- .../Components/Dialogs/UsersDialog.razor.cs | 176 ++++++ .../Components/Pages/AuthorizedTestPage.razor | 26 +- .../Components/Pages/Home.razor | 54 +- .../Components/Pages/Home.razor.cs | 14 +- .../Components/Pages/NotAuthorized.razor | 2 +- .../Components/Pages/Register.razor.cs | 2 +- .../Components/Routes.razor | 3 +- .../App.razor | 70 ++- .../Brand/UAuthLogo.razor | 19 + .../Brand/UAuthLogo.razor.cs | 54 ++ .../Brand/UAuthLogoVariant.cs | 7 + .../Common/UAuthDialog.cs | 29 + .../Custom/UAuthPageComponent.razor | 10 + .../Dialogs/AccountStatusDialog.razor | 23 + .../Dialogs/AccountStatusDialog.razor.cs | 77 +++ .../Components/Dialogs/CreateUserDialog.razor | 27 + .../Dialogs/CreateUserDialog.razor.cs | 55 ++ .../Components/Dialogs/CredentialDialog.razor | 51 ++ .../Dialogs/CredentialDialog.razor.cs | 92 +++ .../Components/Dialogs/IdentifierDialog.razor | 115 ++++ .../Dialogs/IdentifierDialog.razor.cs | 311 ++++++++++ .../Components/Dialogs/PermissionDialog.razor | 46 ++ .../Dialogs/PermissionDialog.razor.cs | 119 ++++ .../Components/Dialogs/ProfileDialog.razor | 103 ++++ .../Components/Dialogs/ProfileDialog.razor.cs | 116 ++++ .../Components/Dialogs/ResetDialog.razor | 38 ++ .../Components/Dialogs/ResetDialog.razor.cs | 42 ++ .../Components/Dialogs/RoleDialog.razor | 90 +++ .../Components/Dialogs/RoleDialog.razor.cs | 175 ++++++ .../Components/Dialogs/SessionDialog.razor | 226 +++++++ .../Components/Dialogs/SessionDialog.razor.cs | 286 +++++++++ .../Components/Dialogs/UserDetailDialog.razor | 75 +++ .../Dialogs/UserDetailDialog.razor.cs | 100 +++ .../Components/Dialogs/UserRoleDialog.razor | 49 ++ .../Dialogs/UserRoleDialog.razor.cs | 112 ++++ .../Components/Dialogs/UsersDialog.razor | 94 +++ .../Components/Dialogs/UsersDialog.razor.cs | 188 ++++++ .../Infrastructure/DarkModeManager.cs | 45 ++ .../Layout/MainLayout.razor | 123 ++-- .../Layout/MainLayout.razor.cs | 130 ++++ .../Layout/MainLayout.razor.css | 77 --- .../Pages/AnonymousTestPage.razor | 1 + .../Pages/AuthorizedTestPage.razor | 26 + .../Pages/Home.razor | 569 +++++++++++++----- .../Pages/Home.razor.cs | 265 +++++--- .../Pages/LandingPage.razor | 4 + .../Pages/LandingPage.razor.cs | 17 + .../Pages/Login.razor | 134 +++++ .../Pages/Login.razor.cs | 200 ++++++ .../Pages/NotAuthorized.razor | 27 + .../Pages/NotAuthorized.razor.cs | 15 + .../Pages/Register.razor | 54 ++ .../Pages/Register.razor.cs | 45 ++ .../Pages/ResetCredential.razor | 18 + .../Pages/ResetCredential.razor.cs | 49 ++ .../Pages/Weather.razor | 57 -- .../Program.cs | 18 +- .../_Imports.razor | 6 +- .../wwwroot/UltimateAuth-Logo.png | Bin 0 -> 14776 bytes .../wwwroot/css/app.css | 94 ++- .../wwwroot/favicon.png | Bin 1148 -> 0 bytes .../wwwroot/icon-192.png | Bin 2626 -> 0 bytes .../wwwroot/index.html | 2 +- .../wwwroot/sample-data/weather.json | 27 - .../Diagnostics/UAuthClientDiagnostics.cs | 7 + .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../UAuthClientPkceLoginFlowOptions.cs | 6 +- .../Services/Abstractions/ISessionClient.cs | 8 +- .../Abstractions/IUserIdentifierClient.cs | 4 +- .../Services/UAuthFlowClient.cs | 20 +- .../Services/UAuthSessionClient.cs | 16 +- .../Services/UAuthUserIdentifierClient.cs | 8 +- ...hainDetailDto.cs => SessionChainDetail.cs} | 4 +- ...inSummaryDto.cs => SessionChainSummary.cs} | 2 +- .../{SessionInfoDto.cs => SessionInfo.cs} | 2 +- .../Defaults/UAuthActions.cs | 4 +- .../Auth/Context/AuthExecutionContext.cs | 4 +- .../Auth/Context/AuthFlowContextFactory.cs | 22 + .../Auth/Context/IAuthFlowContextFactory.cs | 1 + .../Endpoints/PkceEndpointHandler.cs | 10 +- .../Endpoints/UAuthEndpointRegistrar.cs | 4 +- .../Flows/Pkce/PkceAuthorizeRequest.cs | 1 + .../Flows/Refresh/RefreshResponseWriter.cs | 2 + .../Validator/IIdentifierValidator.cs | 2 +- .../Validator/IdentifierValidator.cs | 2 +- .../Validator/UserCreateValidator.cs | 6 +- .../Services/ISessionApplicationService.cs | 4 +- .../Services/SessionApplicationService.cs | 36 +- .../Services/UAuthFlowService.cs | 3 + .../Endpoints/AuthorizationEndpointHandler.cs | 4 +- .../{CredentialDto.cs => CredentialInfo.cs} | 2 +- .../Responses/GetCredentialsResult.cs | 2 +- .../Services/CredentialManagementService.cs | 2 +- ...IdentifierDto.cs => UserIdentifierInfo.cs} | 2 +- .../Dtos/UserIdentifierType.cs | 3 +- ...erMfaStatusDto.cs => UserMfaStatusInfo.cs} | 2 +- .../Responses/GetUserIdentifiersResult.cs | 2 +- .../Domain/UserIdentifier.cs | 4 +- .../Mapping/UserIdentifierMapper.cs | 2 +- .../Services/IUserApplicationService.cs | 4 +- .../Services/UserApplicationService.cs | 12 +- 141 files changed, 6639 insertions(+), 2340 deletions(-) create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogo.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogo.razor.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogoVariant.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotAuthorized.razor create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotAuthorized.razor.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Infrastructure/DarkModeManager.cs create mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/UltimateAuth-Logo.png delete mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/favicon.png create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogo.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogo.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogoVariant.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Common/UAuthDialog.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Custom/UAuthPageComponent.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/AccountStatusDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/AccountStatusDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CreateUserDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CreateUserDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/IdentifierDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/IdentifierDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/SessionDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/SessionDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserDetailDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserDetailDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UsersDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UsersDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Infrastructure/DarkModeManager.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs delete mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.css create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AnonymousTestPage.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/LandingPage.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/LandingPage.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/NotAuthorized.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/NotAuthorized.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs delete mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/UltimateAuth-Logo.png delete mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png delete mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/icon-192.png delete mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json rename src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/{SessionChainDetailDto.cs => SessionChainDetail.cs} (86%) rename src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/{SessionChainSummaryDto.cs => SessionChainSummary.cs} (94%) rename src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/{SessionInfoDto.cs => SessionInfo.cs} (84%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/{CredentialDto.cs => CredentialInfo.cs} (93%) rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/{UserIdentifierDto.cs => UserIdentifierInfo.cs} (89%) rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/{UserMfaStatusDto.cs => UserMfaStatusInfo.cs} (86%) diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogo.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogo.razor new file mode 100644 index 00000000..2806b7d3 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogo.razor @@ -0,0 +1,19 @@ +@namespace CodeBeam.UltimateAuth.Sample +@inherits ComponentBase + + + + @if (Variant == UAuthLogoVariant.Brand) + { + + + + } + else + { + + + + } + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogo.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogo.razor.cs new file mode 100644 index 00000000..030d9b66 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/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/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogoVariant.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogoVariant.cs new file mode 100644 index 00000000..fe3be220 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Brand/UAuthLogoVariant.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Sample; + +public enum UAuthLogoVariant +{ + Brand, + Mono +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index 32b6af51..fd8d083e 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -9,8 +9,10 @@ - - + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor index 7f12ea3d..f9989cb9 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor @@ -6,11 +6,11 @@ - + @* *@ - + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index 3202e106..d8673b17 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -31,10 +31,26 @@ @if (_state == null || !_state.IsActive) { - - This page cannot be accessed directly. - UAuthHub login flows can only be initiated by an authorized client application. - + + + + + + Access Denied + + + This page cannot be accessed directly. + UAuthHub login flows can only be initiated by an authorized client application. + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + return; } diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs index 31199c37..dc0f988e 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -6,131 +6,130 @@ using Microsoft.AspNetCore.WebUtilities; using MudBlazor; -namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages; + +public partial class Home { - public partial class Home - { - [SupplyParameterFromQuery(Name = "hub")] - public string? HubKey { get; set; } + [SupplyParameterFromQuery(Name = "hub")] + public string? HubKey { get; set; } - private string? _username; - private string? _password; + private string? _username; + private string? _password; - private HubFlowState? _state; + private HubFlowState? _state; - protected override async Task OnParametersSetAsync() + protected override async Task OnParametersSetAsync() + { + if (string.IsNullOrWhiteSpace(HubKey)) { - if (string.IsNullOrWhiteSpace(HubKey)) - { - _state = null; - return; - } - - if (!HubSessionId.TryParse(HubKey, out var hubSessionId)) - _state = await HubFlowReader.GetStateAsync(hubSessionId); + _state = null; + return; } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender) - return; - - var currentError = await BrowserStorage.GetAsync(StorageScope.Session, "uauth:last_error"); - - if (!string.IsNullOrWhiteSpace(currentError)) - { - Snackbar.Add(ResolveErrorMessage(currentError), Severity.Error); - await BrowserStorage.RemoveAsync(StorageScope.Session, "uauth:last_error"); - } - - var uri = Nav.ToAbsoluteUri(Nav.Uri); - var query = QueryHelpers.ParseQuery(uri.Query); - - if (query.TryGetValue("__uauth_error", out var error)) - { - await BrowserStorage.SetAsync(StorageScope.Session, "uauth:last_error", error.ToString()); - } - - if (string.IsNullOrWhiteSpace(HubKey)) - { - return; - } - - if (_state is null || !_state.Exists) - return; - - if (_state?.IsActive != true) - { - await StartNewPkceAsync(); - return; - } - } + if (HubSessionId.TryParse(HubKey, out var hubSessionId)) + _state = await HubFlowReader.GetStateAsync(hubSessionId); + } - // For testing & debugging - private async Task ProgrammaticPkceLogin() - { - var hub = _state; + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; - if (hub is null) - return; + var currentError = await BrowserStorage.GetAsync(StorageScope.Session, "uauth:last_error"); - if (!HubSessionId.TryParse(HubKey, out var hubSessionId)) - return; + if (!string.IsNullOrWhiteSpace(currentError)) + { + Snackbar.Add(ResolveErrorMessage(currentError), Severity.Error); + await BrowserStorage.RemoveAsync(StorageScope.Session, "uauth:last_error"); + } - var credentials = await HubCredentialResolver.ResolveAsync(hubSessionId); + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); - var request = new PkceLoginRequest - { - Identifier = "admin", - Secret = "admin", - AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty, - CodeVerifier = credentials?.CodeVerifier ?? string.Empty, - ReturnUrl = _state?.ReturnUrl ?? string.Empty - }; - await UAuthClient.Flows.CompletePkceLoginAsync(request); + if (query.TryGetValue("__uauth_error", out var error)) + { + await BrowserStorage.SetAsync(StorageScope.Session, "uauth:last_error", error.ToString()); + } + + if (string.IsNullOrWhiteSpace(HubKey)) + { + return; } - private async Task StartNewPkceAsync() + if (_state is null || !_state.Exists) + return; + + if (_state?.IsActive != true) { - var returnUrl = await ResolveReturnUrlAsync(); - await UAuthClient.Flows.BeginPkceAsync(returnUrl); + await StartNewPkceAsync(); + return; } + } - private async Task ResolveReturnUrlAsync() + // For testing & debugging + private async Task ProgrammaticPkceLogin() + { + var hub = _state; + + if (hub is null) + return; + + if (!HubSessionId.TryParse(HubKey, out var hubSessionId)) + return; + + var credentials = await HubCredentialResolver.ResolveAsync(hubSessionId); + + var request = new PkceLoginRequest { - var fromContext = _state?.ReturnUrl; - if (!string.IsNullOrWhiteSpace(fromContext)) - return fromContext; + Identifier = "admin", + Secret = "admin", + AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty, + CodeVerifier = credentials?.CodeVerifier ?? string.Empty, + ReturnUrl = _state?.ReturnUrl ?? string.Empty + }; + await UAuthClient.Flows.CompletePkceLoginAsync(request); + } - var uri = Nav.ToAbsoluteUri(Nav.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + private async Task StartNewPkceAsync() + { + var returnUrl = await ResolveReturnUrlAsync(); + await UAuthClient.Flows.BeginPkceAsync(returnUrl); + } - if (query.TryGetValue("return_url", out var ru) && !string.IsNullOrWhiteSpace(ru)) - return ru!; + private async Task ResolveReturnUrlAsync() + { + var fromContext = _state?.ReturnUrl; + if (!string.IsNullOrWhiteSpace(fromContext)) + return fromContext; - 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!; - } + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - // Config default (recommend adding to options) - //if (!string.IsNullOrWhiteSpace(_options.Login.DefaultReturnUrl)) - // return _options.Login.DefaultReturnUrl!; + if (query.TryGetValue("return_url", out var ru) && !string.IsNullOrWhiteSpace(ru)) + return ru!; - return Nav.Uri; - } - - private string ResolveErrorMessage(string? errorKey) + if (query.TryGetValue("hub", out var hubKey) && !string.IsNullOrWhiteSpace(hubKey)) { - if (errorKey == "invalid") - { - return "Login failed."; - } + var artifact = await AuthStore.GetAsync(new AuthArtifactKey(hubKey!)); + if (artifact is HubFlowArtifact flow && !string.IsNullOrWhiteSpace(flow.ReturnUrl)) + return flow.ReturnUrl!; + } + + // Config default (recommend adding to options) + //if (!string.IsNullOrWhiteSpace(_options.Login.DefaultReturnUrl)) + // return _options.Login.DefaultReturnUrl!; - return "Failed attempt."; + return Nav.Uri; + } + + private string ResolveErrorMessage(string? errorKey) + { + if (errorKey == "invalid") + { + return "Login failed."; } + 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/Routes.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor index f06c25ba..9e918850 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor @@ -1,14 +1,59 @@ - - - - - - - - - - - - - +@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages +@using CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure +@inject ISnackbar Snackbar +@inject DarkModeManager DarkModeManager + + + + + + + + + + + + + + + + + + + + +@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/Controllers/HubLoginController.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs index fa7a1ae5..71cb29b3 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs @@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Controllers; -[Route("uauthhub")] +[Route("auth/uauthhub")] [IgnoreAntiforgeryToken] public sealed class HubLoginController : Controller { 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..f8f05cb4 --- /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 IBrowserStorage _storage; + + public DarkModeManager(IBrowserStorage 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 index f3f88149..b0412644 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -1,22 +1,24 @@ using CodeBeam.UltimateAuth.Authentication.InMemory; -using CodeBeam.UltimateAuth.Authorization.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; +using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; -using CodeBeam.UltimateAuth.Users.Reference; using CodeBeam.UltimateAuth.Users.Reference.Extensions; using MudBlazor.Services; using MudExtensions.Services; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -26,18 +28,11 @@ builder.Services.AddControllers(); -builder.Services.AddMudServices(); +builder.Services.AddMudServices(o => { + o.SnackbarConfiguration.PreventDuplicates = false; +}); builder.Services.AddMudExtensions(); -//builder.Services -// .AddAuthentication(options => -// { -// options.DefaultAuthenticateScheme = UAuthSchemeDefaults.AuthenticationScheme; -// options.DefaultSignInScheme = UAuthSchemeDefaults.AuthenticationScheme; -// options.DefaultChallengeScheme = UAuthSchemeDefaults.AuthenticationScheme; -// }) -// .AddUAuthCookies(); - //builder.Services.AddAuthorization(); //builder.Services.AddHttpContextAccessor(); @@ -66,6 +61,7 @@ }); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddCors(options => { @@ -75,33 +71,29 @@ .WithOrigins("https://localhost:6130") .AllowAnyHeader() .AllowAnyMethod() - .AllowCredentials(); + .AllowCredentials() + .WithExposedHeaders("X-UAuth-Refresh"); // TODO: Add exposed headers globally }); }); var app = builder.Build(); -using (var scope = app.Services.CreateScope()) -{ - scope.ServiceProvider.GetRequiredService(); - scope.ServiceProvider.GetRequiredService(); - scope.ServiceProvider.GetRequiredService(); - - var seeder = scope.ServiceProvider.GetService(); - //if (seeder is not null) - // await seeder.SeedAsync(); - - -} - -// Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } -//app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +else +{ + app.MapOpenApi(); + app.MapScalarApiReference(); + + using var scope = app.Services.CreateScope(); + var seedRunner = scope.ServiceProvider.GetRequiredService(); + await seedRunner.RunAsync(null); +} + app.UseHttpsRedirection(); app.UseCors("WasmSample"); @@ -113,7 +105,8 @@ app.MapControllers(); app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .AddUltimateAuthClientRoutes(typeof(UAuthClientMarker).Assembly); app.MapGet("/health", () => { 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 0000000000000000000000000000000000000000..5b7282f15f1b7e435a8e88356ed2351d1edcc26b GIT binary patch literal 14776 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 .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { text-align: start; -} \ No newline at end of file +} + +.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/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/favicon.png b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/favicon.png deleted file mode 100644 index 8422b59695935d180d11d5dbe99653e711097819..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ + @if (Variant == UAuthLogoVariant.Brand) { + d="M32.39,14.07H167.61c11.27,0,18,6.76,18,18V133.52c0,22.54-58.59,69.87-85.64,92.41-27-22.54-85.64-69.87-85.64-92.41V32.1C14.36,20.83,21.12,14.07,32.39,14.07Z" /> - + } else { - + - + } 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 index 7873ce91..0c91e45c 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor @@ -4,7 +4,7 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService - + Identifier Management User: @AuthState?.Identity?.DisplayName @@ -21,73 +21,3 @@ - -@code { - [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 = SelfUserStatus.SelfSuspended }; - var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); - if (result.IsSuccess) - { - Snackbar.Add("Your account suspended successfully.", Severity.Success); - MudDialog.Close(); - } - else - { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "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?.Problem?.Detail ?? result?.Problem?.Title ?? "Delete failed.", Severity.Error); - } - } -} \ No newline at end of file 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..64797ba4 --- /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 = SelfUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Your account suspended successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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?.GetErrorText ?? "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 index 479a08bd..9a514935 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor @@ -9,12 +9,12 @@ - + - + @@ -25,52 +25,3 @@ Create - -@code { - 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 do not match", Severity.Error); - return; - } - - var request = new CreateUserRequest - { - UserName = _username, - Email = _email, - DisplayName = _displayName, - Password = _password - }; - - var result = await UAuthClient.Users.CreateAdminAsync(request); - - if (!result.IsSuccess) - { - Snackbar.Add(result.GetErrorText ?? "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/CreateUserDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor.cs new file mode 100644 index 00000000..bb7998b1 --- /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.CreateAdminAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add(result.GetErrorText ?? "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 index 01c01ff0..660b7c3a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor @@ -49,86 +49,3 @@ Cancel - -@code { - 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.ChangeCredentialAsync(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.GetErrorText ?? "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/CredentialDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs new file mode 100644 index 00000000..1f207f8d --- /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.ChangeCredentialAsync(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.GetErrorText ?? "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 index 808e7fda..0d631533 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -19,7 +19,7 @@ - @@ -73,7 +73,7 @@ - + @@ -104,303 +104,3 @@ Cancel - -@code { - 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.GetMyIdentifiersAsync(); - 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.GetMyIdentifiersAsync(req); - } - else - { - res = await UAuthClient.Identifiers.GetUserIdentifiersAsync(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(UserIdentifierDto item) - { - UpdateUserIdentifierRequest updateRequest = new() - { - Id = item.Id, - NewValue = item.Value - }; - - UAuthResult result; - - if (UserKey is null) - { - result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); - } - else - { - result = await UAuthClient.Identifiers.UpdateAdminAsync(UserKey.Value, updateRequest); - } - - if (result.IsSuccess) - { - Snackbar.Add("Identifier updated successfully", Severity.Success); - } - else - { - Snackbar.Add(result?.GetErrorText ?? "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.AddSelfAsync(request); - } - else - { - result = await UAuthClient.Identifiers.AddAdminAsync(UserKey.Value, request); - } - - if (result.IsSuccess) - { - Snackbar.Add("Identifier added successfully", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else - { - Snackbar.Add(result?.GetErrorText ?? "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() { IdentifierId = id }; - UAuthResult result; - - if (UserKey is null) - { - result = await UAuthClient.Identifiers.VerifySelfAsync(request); - } - else - { - result = await UAuthClient.Identifiers.VerifyAdminAsync(UserKey.Value, request); - } - - if (result.IsSuccess) - { - Snackbar.Add("Identifier verified successfully", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else - { - Snackbar.Add(result?.GetErrorText ?? "Failed to verify primary identifier", Severity.Error); - } - } - - private async Task SetPrimaryAsync(Guid id) - { - SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; - UAuthResult result; - - if (UserKey is null) - { - result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); - } - else - { - result = await UAuthClient.Identifiers.SetPrimaryAdminAsync(UserKey.Value, request); - } - - if (result.IsSuccess) - { - Snackbar.Add("Primary identifier set successfully", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else - { - Snackbar.Add(result?.GetErrorText ?? "Failed to set primary identifier", Severity.Error); - } - } - - private async Task UnsetPrimaryAsync(Guid id) - { - UnsetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; - UAuthResult result; - - if (UserKey is null) - { - result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); - } - else - { - result = await UAuthClient.Identifiers.UnsetPrimaryAdminAsync(UserKey.Value, request); - } - - if (result.IsSuccess) - { - Snackbar.Add("Primary identifier unset successfully", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else - { - Snackbar.Add(result?.GetErrorText ?? "Failed to unset primary identifier", Severity.Error); - } - } - - private async Task DeleteIdentifier(Guid id) - { - DeleteUserIdentifierRequest request = new() { IdentifierId = id }; - UAuthResult result; - - if (UserKey is null) - { - result = await UAuthClient.Identifiers.DeleteSelfAsync(request); - } - else - { - result = await UAuthClient.Identifiers.DeleteAdminAsync(UserKey.Value, request); - } - - if (result.IsSuccess) - { - Snackbar.Add("Identifier deleted successfully", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else - { - Snackbar.Add(result?.GetErrorText ?? "Failed to delete identifier", Severity.Error); - } - } - - private void Cancel() => MudDialog.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..4c789ba6 --- /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.GetMyIdentifiersAsync(); + 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.GetMyIdentifiersAsync(req); + } + else + { + res = await UAuthClient.Identifiers.GetUserIdentifiersAsync(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.UpdateSelfAsync(updateRequest); + } + else + { + result = await UAuthClient.Identifiers.UpdateAdminAsync(UserKey.Value, updateRequest); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier updated successfully", Severity.Success); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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.AddSelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.AddAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier added successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.VerifySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.VerifyAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier verified successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "Failed to verify primary identifier", Severity.Error); + } + } + + private async Task SetPrimaryAsync(Guid id) + { + SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.SetPrimaryAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier set successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "Failed to set primary identifier", Severity.Error); + } + } + + private async Task UnsetPrimaryAsync(Guid id) + { + UnsetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.UnsetPrimaryAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier unset successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "Failed to unset primary identifier", Severity.Error); + } + } + + private async Task DeleteIdentifier(Guid id) + { + DeleteUserIdentifierRequest request = new() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.DeleteAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier deleted successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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 index 77cf7c13..8e0df863 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor @@ -44,117 +44,3 @@ Save - -@code { - - [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(); - } - - void ToggleGroup(PermissionGroup group, bool value) - { - foreach (var item in group.Items) - item.Selected = value; - } - - void TogglePermission(PermissionItem item, bool value) - { - item.Selected = value; - } - - 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 SetPermissionsRequest - { - Permissions = permissions - }; - - var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); - - if (!result.IsSuccess) - { - Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "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; } - } -} \ No newline at end of file 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..844afd4f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor.cs @@ -0,0 +1,119 @@ +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 SetPermissionsRequest + { + Permissions = permissions + }; + + var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + + if (!result.IsSuccess) + { + Snackbar.Add(result.GetErrorText ?? "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 index 7a02975b..d09fcfa0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor @@ -19,7 +19,7 @@ - + @@ -92,108 +92,3 @@ Save - -@code { - 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.GetProfileAsync(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.UpdateProfileAsync(UserKey.Value, request); - } - - if (result.IsSuccess) - { - Snackbar.Add("Profile updated", Severity.Success); - MudDialog.Close(DialogResult.Ok(true)); - } - else - { - Snackbar.Add(result.GetErrorText ?? "Failed to update profile", Severity.Error); - } - } - - private void Cancel() => MudDialog.Cancel(); -} \ No newline at end of file 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..5b861925 --- /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.GetProfileAsync(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.UpdateProfileAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Profile updated", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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 index 1e909a9f..06a515aa 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor @@ -33,43 +33,6 @@ - Cancel - OK + Close - -@code { - 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 BeginCredentialResetRequest - { - 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.Problem?.Detail ?? result.Problem?.Title ?? "Failed to request credential reset.", Severity.Error); - return; - } - - _resetCode = result.Value.Token; - _resetRequested = true; - } - - private void Submit() => MudDialog.Close(DialogResult.Ok(true)); - - private void Cancel() => MudDialog.Cancel(); -} 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..c719539b --- /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 BeginCredentialResetRequest + { + 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.GetErrorText ?? "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 index 0a44b8b9..b78db16f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor @@ -79,160 +79,3 @@ Close - -@code { - 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.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 CommittedItemChanges(RoleInfo role) - { - var req = new RenameRoleRequest - { - Name = role.Name - }; - - var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, req); - - if (result.IsSuccess) - { - Snackbar.Add("Role renamed", Severity.Success); - } - else - { - Snackbar.Add(result.Problem?.Title ?? "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.Problem?.Title ?? "Create 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(); - var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, 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.Problem?.Detail ?? result.Problem?.Title ?? "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(); - -} \ No newline at end of file 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..d77de014 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor.cs @@ -0,0 +1,163 @@ +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.GetErrorText ?? "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 + { + Name = role.Name + }; + + var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, req); + + if (result.IsSuccess) + { + Snackbar.Add("Role renamed", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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.GetErrorText ?? "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(); + var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, 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.GetErrorText ?? "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 index c51e9987..8ecf2a15 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -138,7 +138,7 @@ Revoke Other Devices } - Sessions @@ -148,7 +148,7 @@ - + @@ -206,7 +206,7 @@ - + } @@ -215,278 +215,3 @@ Cancel - -@code { - private MudDataGrid? _grid; - private bool _loading = false; - private bool _reloadQueued; - private SessionChainDetailDto? _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.LogoutAllDevicesSelfAsync(); - } - else - { - result = await UAuthClient.Flows.LogoutAllDevicesAdminAsync(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.GetErrorText ?? "Failed to logout", Severity.Error); - } - } - - private async Task LogoutOthersAsync() - { - var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); - - if (result.IsSuccess) - { - Snackbar.Add("Logged out of other devices.", Severity.Success); - } - else - { - Snackbar.Add(result?.GetErrorText ?? "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.LogoutDeviceSelfAsync(request); - } - else - { - result = await UAuthClient.Flows.LogoutDeviceAdminAsync(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.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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/SessionDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor.cs new file mode 100644 index 00000000..bcf2bb77 --- /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.LogoutAllDevicesSelfAsync(); + } + else + { + result = await UAuthClient.Flows.LogoutAllDevicesAdminAsync(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.GetErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task LogoutOthersAsync() + { + var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of other devices.", Severity.Success); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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.LogoutDeviceSelfAsync(request); + } + else + { + result = await UAuthClient.Flows.LogoutDeviceAdminAsync(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.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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 index 92aeaa8e..4cd64ff5 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor @@ -73,93 +73,3 @@ Close - -@code { - private UserView? _user; - private UserStatus _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.GetProfileAsync(UserKey); - - if (result.IsSuccess) - { - _user = result.Value; - _status = _user?.Status ?? UserStatus.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.ChangeStatusAdminAsync(_user.UserKey, request); - - if (result.IsSuccess) - { - Snackbar.Add("User status updated", Severity.Success); - _user = _user with { Status = _status }; - } - else - { - Snackbar.Add(result.GetErrorText ?? "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(); - } -} \ No newline at end of file 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..f856566a --- /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 UserStatus _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.GetProfileAsync(UserKey); + + if (result.IsSuccess) + { + _user = result.Value; + _status = _user?.Status ?? UserStatus.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.ChangeStatusAdminAsync(_user.UserKey, request); + + if (result.IsSuccess) + { + Snackbar.Add("User status updated", Severity.Success); + _user = _user with { Status = _status }; + } + else + { + Snackbar.Add(result.GetErrorText ?? "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 index 9ae32dfa..6e754848 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor @@ -47,108 +47,3 @@ Close - -@code { - - [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 result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); - - if (result.IsSuccess) - { - _roles.Add(_selectedRole); - Snackbar.Add("Role assigned", Severity.Success); - } - else - { - Snackbar.Add(result.GetErrorText ?? "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 result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); - - if (result.IsSuccess) - { - _roles.Remove(role); - Snackbar.Add("Role removed", Severity.Success); - } - else - { - Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); - } - } - - private void Close() => MudDialog.Close(); -} \ No newline at end of file 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..eb3557c4 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor.cs @@ -0,0 +1,112 @@ +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 result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); + + if (result.IsSuccess) + { + _roles.Add(_selectedRole); + Snackbar.Add("Role assigned", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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 result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); + + if (result.IsSuccess) + { + _roles.Remove(role); + Snackbar.Add("Role removed.", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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 index 39f3c5e1..bf19af4e 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor @@ -83,169 +83,3 @@ Close - -@code { - 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.QueryUsersAsync(req); - - if (!res.IsSuccess || res.Value == null) - { - Snackbar.Add(res.GetErrorText ?? "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.GetErrorText ?? "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(); - } -} \ No newline at end of file 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..ff112f29 --- /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.QueryUsersAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.GetErrorText ?? "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.GetErrorText ?? "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/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor index b059ee89..5dc5d8aa 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor @@ -1,2 +1,26 @@ @page "/authorized-test" -@attribute [Authorize] \ No newline at end of file +@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/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 0518403b..b71e9282 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -11,6 +11,7 @@ @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) { @@ -29,6 +30,22 @@ return; } +@if (AuthState?.Identity?.UserStatus == UserStatus.Suspended) +{ + + + + Your account is suspended. Please contact with administrator. + + + + Logout + + + + return; +} + @@ -45,20 +62,19 @@ - Validate + Validate - Manual Refresh + Manual Refresh - - Logout + + Logout @@ -69,23 +85,23 @@ - Manage Sessions + Manage Sessions - Manage Profile + Manage Profile - Manage Identifiers + Manage Identifiers - Manage Credentials + Manage Credentials - Suspend | Delete Account + Suspend | Delete Account @@ -110,16 +126,18 @@ - User Management + @* *@ + @* *@ + User Management + @* *@ - - - - Role Management - + + @* *@ + Role Management + @* *@ @@ -398,8 +416,8 @@ - Touched - @Diagnostics.RefreshTouchedCount + Touched/Rotated + @Diagnostics.RefreshTouchedCount / @Diagnostics.RefreshRotatedCount 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 index 72aab311..844f6483 100644 --- 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 @@ -177,7 +177,7 @@ private async Task OpenCredentialDialog() private async Task OpenAccountStatusDialog() { - await DialogService.ShowAsync("Manage Account", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + await DialogService.ShowAsync("Manage Account", GetDialogParameters(), UAuthDialog.GetDialogOptions(MaxWidth.ExtraSmall)); } private async Task OpenUserDialog() @@ -213,18 +213,6 @@ private async Task SetAccountActiveAsync() } } - private string? _roles = "Admin"; - private void RefreshHiddenState() - { - if (_roles == "Admin") - { - _roles = "User"; - return; - } - - _roles = "Admin"; - } - public override void Dispose() { base.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 index 2c0e9b77..d8eb7138 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor @@ -1,7 +1,7 @@ @inject NavigationManager Nav - + 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 index 82645070..9a193aa8 100644 --- 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 @@ -39,7 +39,7 @@ private async Task HandleRegisterAsync() } else { - Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to create user.", Severity.Error); + Snackbar.Add(result.GetErrorText ?? "Failed to create user.", Severity.Error); } } } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor index c9f4b753..03c4e497 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor @@ -3,8 +3,7 @@ @inject ISnackbar Snackbar @inject DarkModeManager DarkModeManager - - + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor index b5d250a3..7d8ad8a5 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor @@ -1,29 +1,59 @@ -@inject ISnackbar Snackbar +@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure +@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages +@inject ISnackbar Snackbar +@inject DarkModeManager DarkModeManager + + + + + - - - - - - - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
+ + + + + + + + + + +
@code { - private void HandleReauth() + private async Task HandleReauth() { Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); } -} \ No newline at end of file + + #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/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..eef2e773 --- /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 = SelfUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Your account suspended successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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?.GetErrorText ?? "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..ccff3139 --- /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.CreateAdminAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add(result.GetErrorText ?? "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..c26bb246 --- /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.ChangeCredentialAsync(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.GetErrorText ?? "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..7838af7f --- /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.GetMyIdentifiersAsync(); + 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.GetMyIdentifiersAsync(req); + } + else + { + res = await UAuthClient.Identifiers.GetUserIdentifiersAsync(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.UpdateSelfAsync(updateRequest); + } + else + { + result = await UAuthClient.Identifiers.UpdateAdminAsync(UserKey.Value, updateRequest); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier updated successfully", Severity.Success); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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.AddSelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.AddAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier added successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.VerifySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.VerifyAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier verified successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "Failed to verify primary identifier", Severity.Error); + } + } + + private async Task SetPrimaryAsync(Guid id) + { + SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.SetPrimaryAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier set successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "Failed to set primary identifier", Severity.Error); + } + } + + private async Task UnsetPrimaryAsync(Guid id) + { + UnsetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.UnsetPrimaryAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier unset successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "Failed to unset primary identifier", Severity.Error); + } + } + + private async Task DeleteIdentifier(Guid id) + { + DeleteUserIdentifierRequest request = new() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.DeleteAdminAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier deleted successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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..214c690b --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor.cs @@ -0,0 +1,119 @@ +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 SetPermissionsRequest + { + Permissions = permissions + }; + + var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + + if (!result.IsSuccess) + { + Snackbar.Add(result.GetErrorText ?? "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..ccd61162 --- /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.GetProfileAsync(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.UpdateProfileAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Profile updated", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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..5d6c99a7 --- /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 BeginCredentialResetRequest + { + 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.GetErrorText ?? "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/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..298c87b0 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor.cs @@ -0,0 +1,175 @@ +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.GetErrorText ?? "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 + { + Name = role.Name + }; + + var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, req); + + if (result.IsSuccess) + { + Snackbar.Add("Role renamed", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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.GetErrorText ?? "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(); + var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, 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.GetErrorText ?? "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..3793677a --- /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.LogoutAllDevicesSelfAsync(); + } + else + { + result = await UAuthClient.Flows.LogoutAllDevicesAdminAsync(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.GetErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task LogoutOthersAsync() + { + var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of other devices.", Severity.Success); + } + else + { + Snackbar.Add(result?.GetErrorText ?? "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.LogoutDeviceSelfAsync(request); + } + else + { + result = await UAuthClient.Flows.LogoutDeviceAdminAsync(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.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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?.GetErrorText ?? "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..3a950e53 --- /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..d5046440 --- /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 UserStatus _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.GetProfileAsync(UserKey); + + if (result.IsSuccess) + { + _user = result.Value; + _status = _user?.Status ?? UserStatus.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.ChangeStatusAdminAsync(_user.UserKey, request); + + if (result.IsSuccess) + { + Snackbar.Add("User status updated", Severity.Success); + _user = _user with { Status = _status }; + } + else + { + Snackbar.Add(result.GetErrorText ?? "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..a3b2fa0c --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor.cs @@ -0,0 +1,112 @@ +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 result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); + + if (result.IsSuccess) + { + _roles.Add(_selectedRole); + Snackbar.Add("Role assigned", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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 result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); + + if (result.IsSuccess) + { + _roles.Remove(role); + Snackbar.Add("Role removed.", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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..64dcbf6c --- /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.QueryUsersAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.GetErrorText ?? "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.GetErrorText ?? "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..bd8900e4 --- /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 IBrowserStorage _storage; + + public DarkModeManager(IBrowserStorage 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 index f451950a..2788afc2 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor @@ -1,78 +1,55 @@ @inherits LayoutComponentBase @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar +@inject NavigationManager Nav - - UltimateAuth - Blazor Server Sample + + + UltimateAuth + + Blazor WASM Sample - - - - - - - - Text - - - - - - - - - - - - @(state.Identity?.PrimaryUserName?.Substring(0, 1).ToUpper()) - - - - - - - - - - - - - - @state.Identity?.PrimaryUserName - + + + + + + +
+ + + @((state.Identity?.DisplayName ?? "?").Trim() is var n ? (n.Length >= 2 ? n[..2] : n[..1]) : "?") + + +
+
+ + + @state.Identity?.DisplayName + @string.Join(", ", state.Claims.Roles) - - - Refresh Session - - - - - Logout - -
- - - - Reauthenticate - - - - - - Sign In - - -
-
- -
+ + + + + @if (state.Identity?.SessionState is not null && state.Identity.SessionState != SessionState.Active) + { + + + } + + + + + + + +
@@ -80,17 +57,9 @@
-@code { - [CascadingParameter] - public UAuthState UAuth { get; set; } = default!; - - private async Task Refresh() - { - await UAuthClient.Flows.RefreshAsync(); - } - private async Task Logout() - { - await UAuthClient.Flows.LogoutAsync(); - } -} +
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/Layout/MainLayout.razor.css b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.css deleted file mode 100644 index ecf25e5b..00000000 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.css +++ /dev/null @@ -1,77 +0,0 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} 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 index bcda64ca..984e3912 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -1,145 +1,444 @@ -@page "/" -@page "/login" -@using CodeBeam.UltimateAuth.Client.Authentication -@using CodeBeam.UltimateAuth.Client.Device -@using CodeBeam.UltimateAuth.Client.Diagnostics -@using CodeBeam.UltimateAuth.Client.Infrastructure -@using CodeBeam.UltimateAuth.Client.Runtime -@using CodeBeam.UltimateAuth.Core.Abstractions -@using CodeBeam.UltimateAuth.Core.Runtime -@inject IUAuthStateManager StateManager -@inject IUAuthProductInfoProvider ProductInfo -@inject ISnackbar Snackbar +@page "/home" +@attribute [Authorize] +@inherits UAuthFlowPageBase + @inject IUAuthClient UAuthClient -@inject NavigationManager Nav -@inject IUAuthClientProductInfoProvider ClientProductInfo -@inject AuthenticationStateProvider AuthStateProvider @inject UAuthClientDiagnostics Diagnostics -@inject IUAuthClientBootstrapper Bootstrapper -@inject IDeviceIdProvider DeviceIdProvider - -
- - - - Welcome to UltimateAuth! - - - Login - - - - - Validate - Logout - Refresh - - - - Programmatic Login - Start Pkce Login - +@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 - - @ClientProductInfo.Get().ProductName v @ClientProductInfo.Get().Version - Client Profile: @ClientProductInfo.Get().ClientProfile.ToString() - +@if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) +{ + + + + Your account is suspended. Please active it before continue. + - - StateHasChanged - Refresh Auth State - State of Authentication: - From UltimateAuth: @(Auth?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(Auth?.Identity?.UserKey) - From ASPNET Core: @(_authState?.User?.Identity?.IsAuthenticated == true ? "Authenticated" : "Not Authenticated") - UserId:@(_authState?.User?.Identity?.Name) - - - Authorized context is shown. - - - Not Authorized context is shown. - - - - - This is Admin content. - + + Set Active + Logout + + + return; +} +@if (AuthState?.Identity?.UserStatus == UserStatus.Suspended) +{ + - - UltimateAuth Client Diagnostics - - - - Started: @Diagnostics.StartCount - @Diagnostics.StartedAt - Stopped: @Diagnostics.StopCount - @Diagnostics.StoppedAt - Terminated: @Diagnostics.TerminatedCount - @Diagnostics.TerminatedAt (@Diagnostics.TerminationReason.ToString()) - - - - Refresh Attempts: @Diagnostics.RefreshAttemptCount - Auto: @Diagnostics.AutomaticRefreshCount - Manual: @Diagnostics.ManualRefreshCount - - - Touched Success: @Diagnostics.RefreshTouchedCount - - - No-Op Success: @Diagnostics.RefreshNoOpCount - - - ReauthRequired: @Diagnostics.RefreshReauthRequiredCount - - - Unknown: @Diagnostics.RefreshSuccessCount - - + + Your account is suspended. Please contact with administrator. + + + + Logout + - - -
- -Ping UAuthHub -Ping ResourceApi - -@code { - // private string? _result; - // private Severity _severity = Severity.Info; - - private async Task CallHub() - { - // try - // { - // var client = HttpClientFactory.CreateClient("UAuthHub"); - // var response = await client.GetStringAsync("/health"); - - // _result = $"UAuthHub response: {response}"; - // _severity = Severity.Success; - // } - // catch (Exception ex) - // { - // _result = $"UAuthHub error: {ex.Message}"; - // _severity = Severity.Error; - // } - // Snackbar.Add(_result, _severity); - } - - private async Task CallApi() - { - // try - // { - // var client = HttpClientFactory.CreateClient("ResourceApi"); - // var response = await client.GetStringAsync("/health"); - - // _result = $"ResourceApi response: {response}"; - // _severity = Severity.Success; - // } - // catch (Exception ex) - // { - // _result = $"ResourceApi error: {ex.Message}"; - // _severity = Severity.Error; - // } - // Snackbar.Add(_result, _severity); - } + + 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-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs index 6ecdf083..0a44f4fd 100644 --- 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 @@ -1,125 +1,222 @@ using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Errors; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Components; +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 +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; + +public partial class Home : UAuthFlowPageBase { - public partial class Home - { - [CascadingParameter] - public UAuthState Auth { get; set; } = null!; + private string _selectedAuthState = "UAuthState"; + private ClaimsPrincipal? _aspNetCoreState; - private string? _username; - private string? _password; + private bool _showAdminPreview = false; - private AuthenticationState _authState = null!; + protected override async Task OnInitializedAsync() + { + var initial = await AuthStateProvider.GetAuthenticationStateAsync(); + _aspNetCoreState = initial.User; + AuthStateProvider.AuthenticationStateChanged += OnAuthStateChanged; + Diagnostics.Changed += OnDiagnosticsChanged; + } - protected override async Task OnInitializedAsync() - { - Diagnostics.Changed += OnDiagnosticsChanged; - //_authState = await AuthStateProvider.GetAuthenticationStateAsync(); - } + private void OnAuthStateChanged(Task task) + { + _ = HandleAuthStateChangedAsync(task); + } - protected override async Task OnAfterRenderAsync(bool firstRender) + private async Task HandleAuthStateChangedAsync(Task task) + { + try { - if (firstRender) - { - await StateManager.EnsureAsync(); - _authState = await AuthStateProvider.GetAuthenticationStateAsync(); - StateHasChanged(); - } + var state = await task; + _aspNetCoreState = state.User; + await InvokeAsync(StateHasChanged); } - - private void OnDiagnosticsChanged() + catch { - InvokeAsync(StateHasChanged); - } - private async Task ProgrammaticLogin() - { - var deviceId = await DeviceIdProvider.GetOrCreateAsync(); - var request = new LoginRequest - { - Identifier = "admin", - Secret = "admin", - }; - await UAuthClient.Flows.LoginAsync(request); } + } - private async Task StartPkceLogin() - { - await UAuthClient.Flows.BeginPkceAsync(); - //await UAuthClient.NavigateToHubLoginAsync(Nav.Uri); - } + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } + + private async Task Logout() => await UAuthClient.Flows.LogoutAsync(); - private async Task ValidateAsync() + private async Task RefreshSession() => await UAuthClient.Flows.RefreshAsync(false); + + private async Task Validate() + { + try { var result = await UAuthClient.Flows.ValidateAsync(); - Snackbar.Add( - result.IsValid ? "Session is valid ✅" : $"Session invalid ❌ ({result.State})", - result.IsValid ? Severity.Success : Severity.Error); - } + 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; - private async Task LogoutAsync() + 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) { - await UAuthClient.Flows.LogoutAsync(); - Snackbar.Add("Logged out", Severity.Success); + Snackbar.Add("Network error.", Severity.Error); } - - private async Task RefreshAsync() + catch (UAuthProtocolException) { - await UAuthClient.Flows.RefreshAsync(); + Snackbar.Add("Invalid response.", Severity.Error); } - - private async Task RefreshAuthState() + catch (UAuthException ex) { - await StateManager.OnLoginAsync(); + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); } - - protected override void OnAfterRender(bool firstRender) + catch (Exception ex) { - if (firstRender) - { - var uri = Nav.ToAbsoluteUri(Nav.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - - if (query.TryGetValue("error", out var error)) - { - ShowLoginError(error.ToString()); - ClearQueryString(); - } - } + 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 void ShowLoginError(string code) + 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 { - var message = code switch - { - "invalid" => "Invalid username or password.", - "locked" => "Your account is locked.", - "mfa" => "Multi-factor authentication required.", - _ => "Login failed." - }; + ["AuthState"] = AuthState + }; + } - Snackbar.Add(message, Severity.Error); - } + private async Task SetAccountActiveAsync() + { + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.Active }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); - private void ClearQueryString() + if (result.IsSuccess) { - var uri = new Uri(Nav.Uri); - var clean = uri.GetLeftPart(UriPartial.Path); - Nav.NavigateTo(clean, replace: true); + Snackbar.Add("Account activated successfully.", Severity.Success); } - - public void Dispose() + else { - Diagnostics.Changed -= OnDiagnosticsChanged; + 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..c82f0e04 --- /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..015076b6 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs @@ -0,0 +1,200 @@ +using CodeBeam.UltimateAuth.Client; +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 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..e32cc79c --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor @@ -0,0 +1,54 @@ +@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..3486ae47 --- /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 succesfully.", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "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..ee8b4919 --- /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 CompleteCredentialResetRequest + { + 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/Pages/Weather.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor deleted file mode 100644 index f2defcf2..00000000 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Weather.razor +++ /dev/null @@ -1,57 +0,0 @@ -@page "/weather" -@inject HttpClient Http - -Weather - -

Weather

- -

This component demonstrates fetching data from the server.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@code { - private WeatherForecast[]? forecasts; - - protected override async Task OnInitializedAsync() - { - forecasts = await Http.GetFromJsonAsync("sample-data/weather.json"); - } - - public class WeatherForecast - { - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public string? Summary { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - } -} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 51de6048..0412ee70 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -1,6 +1,8 @@ using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using MudBlazor.Services; @@ -15,17 +17,19 @@ builder.Services.AddUltimateAuth(); builder.Services.AddUltimateAuthClient(o => { - o.Endpoints.BasePath = "https://localhost:6110"; + o.Endpoints.BasePath = "https://localhost:6110/auth"; + o.Reauth.Behavior = ReauthBehavior.RaiseEvent; + o.Login.AllowCredentialPost = true; + o.Pkce.ReturnUrl = "https://localhost:6130/home"; }); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); - -builder.Services.AddAuthorizationCore(); - -builder.Services.AddMudServices(); +builder.Services.AddMudServices(o => { + o.SnackbarConfiguration.PreventDuplicates = false; +}); builder.Services.AddMudExtensions(); +builder.Services.AddScoped(); + //builder.Services.AddHttpClient("UAuthHub", client => //{ // client.BaseAddress = new Uri("https://localhost:6110"); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor index cdb34a97..7fe2bd88 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor @@ -2,6 +2,7 @@ @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 @@ -10,8 +11,11 @@ @using UltimateAuth.Sample.BlazorStandaloneWasm @using UltimateAuth.Sample.BlazorStandaloneWasm.Layout -@using CodeBeam.UltimateAuth.Core +@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 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 0000000000000000000000000000000000000000..5b7282f15f1b7e435a8e88356ed2351d1edcc26b GIT binary patch literal 14776 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 .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { text-align: start; -} \ No newline at end of file +} + +.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/favicon.png b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/favicon.png deleted file mode 100644 index 8422b59695935d180d11d5dbe99653e711097819..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1148 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 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 index d0fc7487..879067ed 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html @@ -7,7 +7,7 @@ UltimateAuth.Sample.BlazorStandaloneWasm - + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json deleted file mode 100644 index b7459733..00000000 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/sample-data/weather.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2022-01-06", - "temperatureC": 1, - "summary": "Freezing" - }, - { - "date": "2022-01-07", - "temperatureC": 14, - "summary": "Bracing" - }, - { - "date": "2022-01-08", - "temperatureC": -13, - "summary": "Freezing" - }, - { - "date": "2022-01-09", - "temperatureC": -16, - "summary": "Balmy" - }, - { - "date": "2022-01-10", - "temperatureC": -2, - "summary": "Chilly" - } -] diff --git a/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs index 927cb991..eb21d03a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs +++ b/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs @@ -27,6 +27,7 @@ public sealed class UAuthClientDiagnostics 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; } @@ -75,6 +76,12 @@ internal void MarkRefreshTouched() Changed?.Invoke(); } + internal void MarkRefreshRotated() + { + RefreshRotatedCount++; + Changed?.Invoke(); + } + internal void MarkRefreshNoOp() { RefreshNoOpCount++; diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 9223b29b..19cb5c77 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -99,8 +99,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); - //services.AddScoped(); - //services.AddScoped>(sp => sp.GetRequiredService()); + services.AddAuthorizationCore(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs index 6052ba7c..16fa248c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs @@ -9,17 +9,17 @@ public sealed class UAuthClientPkceLoginFlowOptions ///
public bool Enabled { get; set; } = true; - public string? ReturnUrl { get; init; } + public string? ReturnUrl { get; set; } /// /// Called after authorization_code is issued, /// before redirecting to the Hub. /// - public Func? OnAuthorized { get; init; } + public Func? OnAuthorized { get; set; } /// /// If false, BeginPkceAsync will NOT redirect automatically. /// Caller is responsible for navigation. /// - public bool AutoRedirect { get; init; } = true; + public bool AutoRedirect { get; set; } = true; } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs index d4812565..1ecb6eb4 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs @@ -5,15 +5,15 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface ISessionClient { - Task>> GetMyChainsAsync(PageRequest? request = null); - Task> GetMyChainDetailAsync(SessionChainId chainId); + 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>> 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); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs index cc2190e8..9b1ee616 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IUserIdentifierClient { - Task>> GetMyIdentifiersAsync(PageRequest? request = null); + Task>> GetMyIdentifiersAsync(PageRequest? request = null); Task AddSelfAsync(AddUserIdentifierRequest request); Task UpdateSelfAsync(UpdateUserIdentifierRequest request); Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); @@ -14,7 +14,7 @@ public interface IUserIdentifierClient Task VerifySelfAsync(VerifyUserIdentifierRequest request); Task DeleteSelfAsync(DeleteUserIdentifierRequest request); - Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null); + Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null); Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 4a04c194..a9e68bf4 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -22,14 +22,16 @@ internal class UAuthFlowClient : IFlowClient { private readonly IUAuthRequestClient _post; private readonly IUAuthClientEvents _events; + private readonly IDeviceIdProvider _deviceIdProvider; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; private readonly NavigationManager _nav; - public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) + public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IDeviceIdProvider deviceIdProvider, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) { _post = post; _events = events; + _deviceIdProvider = deviceIdProvider; _options = options.Value; _diagnostics = diagnostics; _nav = nav; @@ -101,6 +103,9 @@ public async Task RefreshAsync(bool isAuto = false) case RefreshOutcome.Touched: _diagnostics.MarkRefreshTouched(); break; + case RefreshOutcome.Rotated: + _diagnostics.MarkRefreshRotated(); + break; case RefreshOutcome.ReauthRequired: _diagnostics.MarkRefreshReauthRequired(); break; @@ -168,6 +173,7 @@ public async Task ValidateAsync() public async Task BeginPkceAsync(string? returnUrl = null) { var pkce = _options.Pkce; + var deviceId = await _deviceIdProvider.GetOrCreateAsync(); if (!pkce.Enabled) throw new InvalidOperationException("PKCE login is disabled by configuration."); @@ -182,7 +188,8 @@ public async Task BeginPkceAsync(string? returnUrl = null) new Dictionary { ["code_challenge"] = challenge, - ["challenge_method"] = "S256" + ["challenge_method"] = "S256", + ["device_id"] = deviceId.Value }); if (!raw.Ok || raw.Body is null) @@ -205,7 +212,7 @@ public async Task BeginPkceAsync(string? returnUrl = null) if (pkce.AutoRedirect) { - await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl); + await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl, deviceId.Value); } } @@ -229,7 +236,7 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) ["return_url"] = request.ReturnUrl, ["Identifier"] = request.Identifier ?? string.Empty, - ["Secret"] = request.Secret ?? string.Empty + ["Secret"] = request.Secret ?? string.Empty, }; await _post.NavigateAsync(url, payload); @@ -289,7 +296,7 @@ public async Task LogoutAllDevicesAdminAsync(UserKey userKey) } - private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) + private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl, string deviceId) { var hubLoginUrl = Url(_options.Endpoints.HubLoginPath); @@ -298,7 +305,8 @@ private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifi ["authorization_code"] = authorizationCode, ["code_verifier"] = codeVerifier, ["return_url"] = returnUrl, - ["client_profile"] = _options.ClientProfile.ToString() + ["client_profile"] = _options.ClientProfile.ToString(), + ["device_id"] = deviceId }; return _post.NavigateAsync(hubLoginUrl, data); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs index b9740e7c..90a38e5b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -24,17 +24,17 @@ private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task>> GetMyChainsAsync(PageRequest? request = null) + public async Task>> GetMyChainsAsync(PageRequest? request = null) { request ??= new PageRequest(); var raw = await _request.SendJsonAsync(Url("/me/sessions/chains"), request); - return UAuthResultMapper.FromJson>(raw); + return UAuthResultMapper.FromJson>(raw); } - public async Task> GetMyChainDetailAsync(SessionChainId chainId) + public async Task> GetMyChainDetailAsync(SessionChainId chainId) { var raw = await _request.SendFormAsync(Url($"/me/sessions/chains/{chainId}")); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task> RevokeMyChainAsync(SessionChainId chainId) @@ -70,17 +70,17 @@ public async Task RevokeAllMyChainsAsync() } - public async Task>> GetUserChainsAsync(UserKey userKey, PageRequest? request = null) + public async Task>> GetUserChainsAsync(UserKey userKey, PageRequest? request = null) { request ??= new PageRequest(); var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/sessions/chains"), request); - return UAuthResultMapper.FromJson>(raw); + return UAuthResultMapper.FromJson>(raw); } - public async Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId) + public async Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId) { var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains/{chainId}")); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId) diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index 9f047dc3..15d6593f 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -23,11 +23,11 @@ public UAuthUserIdentifierClient(IUAuthRequestClient request, IUAuthClientEvents private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task>> GetMyIdentifiersAsync(PageRequest? request = null) + public async Task>> GetMyIdentifiersAsync(PageRequest? request = null) { request ??= new PageRequest(); var raw = await _request.SendJsonAsync(Url("/me/identifiers/get"), request); - return UAuthResultMapper.FromJson>(raw); + return UAuthResultMapper.FromJson>(raw); } public async Task AddSelfAsync(AddUserIdentifierRequest request) @@ -90,11 +90,11 @@ public async Task DeleteSelfAsync(DeleteUserIdentifierRequest reque return UAuthResultMapper.From(raw); } - public async Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null) + public async Task>> GetUserIdentifiersAsync(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); + return UAuthResultMapper.FromJson>(raw); } public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetail.cs similarity index 86% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetail.cs index edea1d4b..d8977be4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetail.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class SessionChainDetailDto +public sealed class SessionChainDetail { public SessionChainId ChainId { get; init; } @@ -24,5 +24,5 @@ public sealed class SessionChainDetailDto public AuthSessionId? ActiveSessionId { get; init; } - public IReadOnlyList Sessions { get; init; } = []; + public IReadOnlyList Sessions { get; init; } = []; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs index 51debfc8..3782b0c6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record SessionChainSummaryDto +public sealed record SessionChainSummary { public required SessionChainId ChainId { get; init; } public string? DeviceType { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfo.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfo.cs index cbf966e1..a04eac65 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfo.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record SessionInfoDto( +public sealed record SessionInfo( AuthSessionId SessionId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index faf1dc22..58fc735b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -118,8 +118,8 @@ public static class Authorization public static class Roles { - public const string ReadSelf = "authorization.roles.read.self"; - public const string ReadAdmin = "authorization.roles.read.admin"; + 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"; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs index c163f0fd..74abb271 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs @@ -1,8 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Options; +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/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index bbc26679..fdefb918 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -145,4 +145,26 @@ public async ValueTask RecreateWithClientProfileAsync(AuthFlowC 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/IAuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs index d1679c40..ecb7e738 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs @@ -8,4 +8,5 @@ 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/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index b5bbd9e3..419fbc16 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -48,6 +48,7 @@ public async Task AuthorizeAsync(HttpContext ctx) { var authContext = _authContext.Current; + // TODO: Make PKCE flow free if (authContext.FlowType != AuthFlowType.Login) return Results.BadRequest("PKCE is only supported for login flow."); @@ -67,7 +68,7 @@ public async Task AuthorizeAsync(HttpContext ctx) clientProfile: authContext.ClientProfile, tenant: authContext.Tenant, redirectUri: request.RedirectUri, - deviceId: string.Empty // TODO: Fix here with device binding + deviceId: request.DeviceId ); var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds); @@ -114,7 +115,7 @@ public async Task CompleteAsync(HttpContext ctx) clientProfile: authContext.ClientProfile, tenant: authContext.Tenant, redirectUri: null, - deviceId: string.Empty), + deviceId: artifact.Context.DeviceId), _clock.UtcNow); if (!validation.Success) @@ -135,6 +136,7 @@ public async Task CompleteAsync(HttpContext ctx) var execution = new AuthExecutionContext { EffectiveClientProfile = artifact.Context.ClientProfile, + Device = DeviceContext.Create(DeviceId.Create(artifact.Context.DeviceId), null, null, null, null, null) }; var result = await _flow.LoginAsync(authContext, execution, loginRequest, ctx.RequestAborted); @@ -178,12 +180,14 @@ public async Task CompleteAsync(HttpContext ctx) var codeChallenge = form["code_challenge"].ToString(); var challengeMethod = form["challenge_method"].ToString(); var redirectUri = form["redirect_uri"].ToString(); + var deviceId = form["device_id"].ToString(); return new PkceAuthorizeRequest { CodeChallenge = codeChallenge, ChallengeMethod = challengeMethod, - RedirectUri = string.IsNullOrWhiteSpace(redirectUri) ? null : redirectUri + RedirectUri = string.IsNullOrWhiteSpace(redirectUri) ? null : redirectUri, + DeviceId = deviceId }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 0fc21b33..fd21d395 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -352,12 +352,12 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options selfAuthz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - if (Enabled(UAuthActions.Authorization.Roles.ReadSelf)) + 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.ReadAdmin)) + 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)); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs index 50622f30..9b5d390b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs @@ -5,4 +5,5 @@ internal sealed class PkceAuthorizeRequest public string CodeChallenge { get; init; } = default!; public string ChallengeMethod { get; init; } = default!; public string? RedirectUri { get; init; } + public string? DeviceId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs index 324d70ec..46aaaf6c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs @@ -24,7 +24,9 @@ public void Write(HttpContext context, RefreshOutcome outcome) { 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/Infrastructure/Validator/IIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs index 21243698..8054fa86 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs @@ -5,5 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IIdentifierValidator { - Task ValidateAsync(AccessContext context, UserIdentifierDto identifier, CancellationToken ct = default); + 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 index 3fb399b3..627c73e1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs @@ -15,7 +15,7 @@ public IdentifierValidator(IOptions options) _options = options.Value.IdentifierValidation; } - public Task ValidateAsync(AccessContext context, UserIdentifierDto identifier, CancellationToken ct = default) + public Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs index b08b2150..fdbc5c37 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -25,7 +25,7 @@ public async Task ValidateAsync(AccessContext context if (!string.IsNullOrWhiteSpace(request.UserName)) { - var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierInfo() { Type = UserIdentifierType.Username, Value = request.UserName @@ -36,7 +36,7 @@ public async Task ValidateAsync(AccessContext context if (!string.IsNullOrWhiteSpace(request.Email)) { - var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierInfo() { Type = UserIdentifierType.Email, Value = request.Email @@ -47,7 +47,7 @@ public async Task ValidateAsync(AccessContext context if (!string.IsNullOrWhiteSpace(request.Phone)) { - var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierInfo() { Type = UserIdentifierType.Phone, Value = request.Phone diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs index 3078e595..6d53040f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs @@ -5,9 +5,9 @@ namespace CodeBeam.UltimateAuth.Server.Services; public interface ISessionApplicationService { - Task> GetUserChainsAsync(AccessContext context,UserKey userKey, PageRequest request, CancellationToken ct = default); + Task> GetUserChainsAsync(AccessContext context,UserKey userKey, PageRequest request, CancellationToken ct = default); - Task GetUserChainDetailAsync(AccessContext context, UserKey userKey, SessionChainId chainId, 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); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index 8e6d6ad9..4b62d6ab 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -19,9 +19,9 @@ public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessio _clock = clock; } - public async Task> GetUserChainsAsync(AccessContext context, UserKey userKey, PageRequest request, CancellationToken ct = default) + public async Task> GetUserChainsAsync(AccessContext context, UserKey userKey, PageRequest request, CancellationToken ct = default) { - var command = new AccessCommand>(async innerCt => + var command = new AccessCommand>(async innerCt => { var store = _storeFactory.Create(context.ResourceTenant); request = request.Normalize(); @@ -32,43 +32,43 @@ public async Task> GetUserChainsAsync(Access { chains = request.SortBy switch { - nameof(SessionChainSummaryDto.ChainId) => request.Descending + nameof(SessionChainSummary.ChainId) => request.Descending ? chains.OrderByDescending(x => x.ChainId).ToList() : chains.OrderBy(x => x.Version).ToList(), - nameof(SessionChainSummaryDto.CreatedAt) => request.Descending + nameof(SessionChainSummary.CreatedAt) => request.Descending ? chains.OrderByDescending(x => x.CreatedAt).ToList() : chains.OrderBy(x => x.Version).ToList(), - nameof(SessionChainSummaryDto.LastSeenAt) => request.Descending + nameof(SessionChainSummary.LastSeenAt) => request.Descending ? chains.OrderByDescending(x => x.LastSeenAt).ToList() : chains.OrderBy(x => x.LastSeenAt).ToList(), - nameof(SessionChainSummaryDto.RevokedAt) => request.Descending + nameof(SessionChainSummary.RevokedAt) => request.Descending ? chains.OrderByDescending(x => x.RevokedAt).ToList() : chains.OrderBy(x => x.RevokedAt).ToList(), - nameof(SessionChainSummaryDto.DeviceType) => request.Descending + nameof(SessionChainSummary.DeviceType) => request.Descending ? chains.OrderByDescending(x => x.Device.DeviceType).ToList() : chains.OrderBy(x => x.Device.DeviceType).ToList(), - nameof(SessionChainSummaryDto.OperatingSystem) => request.Descending + nameof(SessionChainSummary.OperatingSystem) => request.Descending ? chains.OrderByDescending(x => x.Device.OperatingSystem).ToList() : chains.OrderBy(x => x.Device.OperatingSystem).ToList(), - nameof(SessionChainSummaryDto.Platform) => request.Descending + nameof(SessionChainSummary.Platform) => request.Descending ? chains.OrderByDescending(x => x.Device.Platform).ToList() : chains.OrderBy(x => x.Device.Platform).ToList(), - nameof(SessionChainSummaryDto.Browser) => request.Descending + nameof(SessionChainSummary.Browser) => request.Descending ? chains.OrderByDescending(x => x.Device.Browser).ToList() : chains.OrderBy(x => x.Device.Browser).ToList(), - nameof(SessionChainSummaryDto.RotationCount) => request.Descending + nameof(SessionChainSummary.RotationCount) => request.Descending ? chains.OrderByDescending(x => x.RotationCount).ToList() : chains.OrderBy(x => x.RotationCount).ToList(), - nameof(SessionChainSummaryDto.TouchCount) => request.Descending + nameof(SessionChainSummary.TouchCount) => request.Descending ? chains.OrderByDescending(x => x.TouchCount).ToList() : chains.OrderBy(x => x.TouchCount).ToList(), @@ -81,7 +81,7 @@ public async Task> GetUserChainsAsync(Access var pageItems = chains .Skip((request.PageNumber - 1) * request.PageSize) .Take(request.PageSize) - .Select(c => new SessionChainSummaryDto + .Select(c => new SessionChainSummary { ChainId = c.ChainId, DeviceType = c.Device.DeviceType, @@ -100,15 +100,15 @@ public async Task> GetUserChainsAsync(Access }) .ToList(); - return new PagedResult(pageItems, total, request.PageNumber, request.PageSize, request.SortBy, request.Descending); + 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) + public async Task GetUserChainDetailAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var store = _storeFactory.Create(context.ResourceTenant); var chain = await store.GetChainAsync(chainId) ?? throw new UAuthNotFoundException("chain_not_found"); @@ -118,7 +118,7 @@ public async Task GetUserChainDetailAsync(AccessContext c var sessions = await store.GetSessionsByChainAsync(chainId); - return new SessionChainDetailDto + return new SessionChainDetail { ChainId = chain.ChainId, DeviceType = chain.Device.DeviceType, @@ -136,7 +136,7 @@ public async Task GetUserChainDetailAsync(AccessContext c Sessions = sessions .OrderByDescending(x => x.CreatedAt) - .Select(s => new SessionInfoDto( + .Select(s => new SessionInfo( s.SessionId, s.CreatedAt, s.ExpiresAt, diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index caafda9e..cc247a8e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -41,6 +41,9 @@ public async Task LoginAsync(AuthFlowContext flow, AuthExecutionCon 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); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index 1c93e22e..172bad57 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -73,7 +73,7 @@ public async Task GetMyRolesAsync(HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Authorization.Roles.ReadSelf, + action: UAuthActions.Authorization.Roles.GetSelf, resource: "authorization.roles", resourceId: flow.UserKey!.Value ); @@ -97,7 +97,7 @@ public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Authorization.Roles.ReadAdmin, + action: UAuthActions.Authorization.Roles.GetAdmin, resource: "authorization.roles", resourceId: userKey.Value ); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs similarity index 93% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs index 2c3df24a..6141c3d0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record CredentialDto +public sealed record CredentialInfo { public Guid Id { get; set; } public CredentialType Type { get; init; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs index ce621456..8a8a5315 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs @@ -2,5 +2,5 @@ public sealed record GetCredentialsResult { - public IReadOnlyCollection Credentials { get; init; } = Array.Empty(); + public IReadOnlyCollection Credentials { get; init; } = Array.Empty(); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 568a6788..eaaa8a65 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -67,7 +67,7 @@ public async Task GetAllAsync(AccessContext context, Cance var dtos = credentials .OfType() - .Select(c => new CredentialDto + .Select(c => new CredentialInfo { Id = c.Id, Type = c.Type, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierInfo.cs similarity index 89% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierInfo.cs index e8fc7fa7..4c3d381a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierInfo.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed record UserIdentifierDto : IVersionedEntity +public sealed record UserIdentifierInfo : IVersionedEntity { public Guid Id { get; set; } public required UserIdentifierType Type { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs index 4694a8c6..7267c203 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs @@ -4,5 +4,6 @@ public enum UserIdentifierType { Username, Email, - Phone + Phone, + Custom } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs similarity index 86% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs index 736ab39b..ae13171f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed record UserMfaStatusDto +public sealed record UserMfaStatusInfo { public bool IsEnabled { get; init; } public IReadOnlyCollection EnabledMethods { get; init; } = Array.Empty(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs index a0def373..4a78263a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs @@ -2,5 +2,5 @@ public sealed record GetUserIdentifiersResult { - public required IReadOnlyCollection Identifiers { get; init; } + public required IReadOnlyCollection Identifiers { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index 88151a2b..a38424d4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -144,9 +144,9 @@ public UserIdentifier MarkDeleted(DateTimeOffset at) return this; } - public UserIdentifierDto ToDto() + public UserIdentifierInfo ToDto() { - return new UserIdentifierDto() + return new UserIdentifierInfo() { Id = Id, Type = Type, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs index f3ad993f..ff65fed1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public static class UserIdentifierMapper { - public static UserIdentifierDto ToDto(UserIdentifier record) + public static UserIdentifierInfo ToDto(UserIdentifier record) => new() { Id = record.Id, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 0bdb2192..5c9157f1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -15,9 +15,9 @@ public interface IUserApplicationService Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); - Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default); + Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default); - Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, 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); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index fbc7dce2..0044d49c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -305,9 +305,9 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq #region Identifiers - public async Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default) + public async Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default) { - var command = new AccessCommand>(async innerCt => + var command = new AccessCommand>(async innerCt => { var targetUserKey = context.GetTargetUserKey(); @@ -317,7 +317,7 @@ public async Task> GetIdentifiersByUserAsync(Acce var result = await _identifierStore.QueryAsync(context.ResourceTenant, query, innerCt); var dtoItems = result.Items.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); - return new PagedResult( + return new PagedResult( dtoItems, result.TotalCount, result.PageNumber, @@ -329,9 +329,9 @@ public async Task> GetIdentifiersByUserAsync(Acce return await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) + public async Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var normalized = _identifierNormalizer.Normalize(type, value); if (!normalized.IsValid) @@ -365,7 +365,7 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie { var command = new AccessCommand(async innerCt => { - var validationDto = new UserIdentifierDto() { Type = request.Type, Value = request.Value }; + var validationDto = new UserIdentifierInfo() { Type = request.Type, Value = request.Value }; var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); if (validationResult.IsValid != true) { From abe186e61485dd93ee99b863bf7d0194f41dfebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:51:24 +0300 Subject: [PATCH 36/50] EFCore Store Implementation (#23) * EFCore Store Implementation * Completed EFCore Session Store * Completed EFCore Token Store * Completed EFCore User Store * Completed EFCore Credential Store * Completed EFCore Authorization Store --- UltimateAuth.slnx | 3 + .../Abstractions/Stores/IRefreshTokenStore.cs | 21 +- .../Stores/IRefreshTokenStoreFactory.cs | 8 + .../AssemblyVisibility.cs | 4 + .../Contracts/Authority/AccessContext.cs | 3 +- .../Contracts/Login/LoginResult.cs | 2 +- .../Contracts/Refresh/RefreshFlowResult.cs | 4 +- .../Contracts/Token/AuthTokens.cs | 2 +- .../{RefreshToken.cs => RefreshTokenInfo.cs} | 2 +- .../Token/RefreshTokenRotationResult.cs | 4 +- .../Domain/Device/DeviceContext.cs | 10 +- .../Domain/Session/UAuthSession.cs | 26 +- .../Domain/Session/UAuthSessionChain.cs | 1 - .../Domain/Session/UAuthSessionRoot.cs | 11 +- .../Domain/Token/RefreshToken.cs | 88 +++++ .../Domain/Token/StoredRefreshToken.cs | 36 -- .../Domain/Token/TokenId.cs | 51 +++ .../AuthSessionIdJsonConverter.cs | 0 .../SessionChainIdJsonConverter.cs | 0 .../SessionRootIdJsonConverter.cs | 0 .../TenantKeyJsonConverter.cs | 0 .../Converters/TokenIdJsonConverter.cs | 26 ++ .../{ => Converters}/UAuthUserIdConverter.cs | 0 .../{ => Converters}/UserKeyJsonConverter.cs | 0 .../UAuthRefreshTokenValidator.cs | 11 +- .../Abstractions/ICredentialResponseWriter.cs | 2 +- .../Abstractions/ITokenIssuer.cs | 2 +- .../Flows/Login/LoginOrchestrator.cs | 34 +- .../Credentials/CredentialResponseWriter.cs | 2 +- .../Issuers/UAuthSessionIssuer.cs | 2 + .../Issuers/UAuthTokenIssuer.cs | 41 +-- .../Services/RefreshTokenRotationService.cs | 62 ++-- .../Domain/Permission.cs | 2 + .../AssemblyVisibility.cs | 3 + ...h.Authorization.EntityFrameworkCore.csproj | 18 + .../Data/UAuthAuthorizationDbContext.cs | 134 ++++++++ .../Extensions/ServiceCollectionExtensions.cs | 15 + .../Mappers/RoleMapper.cs | 42 +++ .../Mappers/RolePermissionMapper.cs | 23 ++ .../Mappers/UserRoleMapper.cs | 26 ++ .../Projections/RolePermissionProjection.cs | 13 + .../Projections/RoleProjection.cs | 23 ++ .../Projections/UserRoleProjection.cs | 16 + .../Stores/EfCoreRoleStore.cs | 277 +++++++++++++++ .../Stores/EfCoreUserRoleStore.cs | 93 +++++ .../Services/UserRoleService.cs | 3 +- .../Domain/Role.cs | 33 +- .../Dtos/CredentialSecurityState.cs | 4 +- .../Abstractions/CredentialUserMapping.cs | 10 - ...uth.Credentials.EntityFrameworkCore.csproj | 14 +- .../Configuration/ConventionResolver.cs | 23 -- .../CredentialUserMappingBuilder.cs | 74 ---- .../CredentialUserMappingOptions.cs | 29 -- .../Data/UAuthCredentialDbContext.cs | 70 ++++ .../EfCoreAuthUser.cs | 15 - .../Extensions/ServiceCollectionExtensions.cs | 15 + .../PasswordCredentialProjectionMapper.cs | 67 ++++ .../PasswordCredentialProjection.cs | 33 ++ .../ServiceCollectionExtensions.cs | 13 - .../Stores/EfCorePasswordCredentialStore.cs | 146 ++++++++ .../InMemoryCredentialSeedContributor.cs | 4 +- .../InMemoryCredentialStore.cs | 87 ----- .../InMemoryPasswordCredentialState.cs | 15 - .../InMemoryPasswordCredentialStore.cs | 64 ++++ .../ServiceCollectionExtensions.cs | 5 +- .../Domain/PasswordCredential.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../IPasswordCredentialStore.cs | 14 + .../PasswordCredentialProvider.cs | 30 ++ .../PasswordUserLifecycleIntegration.cs | 4 +- .../Services/CredentialManagementService.cs | 11 +- .../Abstractions/ICredential.cs | 16 +- .../Abstractions/ICredentialDescriptor.cs | 13 - .../Abstractions/ICredentialProvider.cs | 13 + .../Abstractions/ICredentialStore.cs | 31 +- .../Infrastructure/CredentialValidator.cs | 2 +- ...th.EntityFrameworkCore.Abstractions.csproj | 28 ++ .../AuthSessionIdEfConverter.cs | 5 +- .../Infrastructure/JsonSerializeWrapper.cs | 28 ++ .../Infrastructure/JsonValueComparers.cs | 64 ++++ .../Infrastructure/JsonValueConverter.cs | 18 + .../NullableAuthSessionIdConverter.cs | 6 +- .../NullableJsonValueConverter.cs | 18 + .../NullableSessionChainIdConverter.cs | 14 + .../Infrastructure/SessionChainIdConverter.cs | 14 + .../SessionChainIdEfConverter.cs | 23 ++ ...teAuth.Sessions.EntityFrameworkCore.csproj | 14 +- .../Data/UAuthSessionDbContext.cs | 57 +++- .../Extensions/ServiceCollectionExtensions.cs | 9 +- .../Infrastructure/JsonValueConverter.cs | 14 - .../Mappers/SessionChainProjectionMapper.cs | 4 + .../Mappers/SessionProjectionMapper.cs | 6 +- .../Mappers/SessionRootProjectionMapper.cs | 3 - .../SessionChainProjection.cs | 3 +- .../SessionProjection.cs | 5 +- .../SessionRootProjection.cs | 3 +- .../Stores/EfCoreSessionStore.cs | 147 +++----- ...mateAuth.Tokens.EntityFrameworkCore.csproj | 14 +- .../{ => Data}/UAuthTokenDbContext.cs | 51 +-- .../EfCoreTokenStore.cs | 110 ------ .../ServiceCollectionExtensions.cs | 4 +- .../Mappers/RefreshTokenProjectionMapper.cs | 43 +++ .../Projections/RefreshTokenProjection.cs | 15 +- .../Stores/EfCoreRefreshTokenStore.cs | 162 +++++++++ .../Stores/EfCoreRefreshTokenStoreFactory.cs | 20 ++ .../InMemoryRefreshTokenStore.cs | 109 +++--- .../InMemoryRefreshTokenStoreFactory.cs | 15 + .../ServiceCollectionExtensions.cs | 2 +- .../AssemblyVisibility.cs | 3 + ...imateAuth.Users.EntityFrameworkCore.csproj | 17 + .../Data/UAuthUserDbContext.cs | 151 ++++++++ .../Extensions/ServiceCollectionExtensions.cs | 17 + .../Mappers/UserIdentifierMapper.cs | 42 +++ .../Mappers/UserLifecycleMapper.cs | 36 ++ .../Mappers/UserProfileMapper.cs | 52 +++ .../Projections/UserIdentifierProjections.cs | 32 ++ .../Projections/UserLifecycleProjection.cs | 26 ++ .../Projections/UserProfileProjection.cs | 41 +++ .../Stores/EfCoreUserIdentifierStore.cs | 322 ++++++++++++++++++ .../Stores/EfCoreUserLifecycleStore.cs | 159 +++++++++ .../Stores/EfCoreUserProfileStore.cs | 163 +++++++++ .../Domain/UserIdentifier.cs | 31 ++ .../Domain/UserLifecycle.cs | 25 ++ .../Domain/UserProfile.cs | 41 +++ .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 3 + .../Core/RefreshTokenValidatorTests.cs | 176 ++++++++-- .../Core/UAuthSessionTests.cs | 2 + .../CredentialUserMappingBuilderTests.cs | 94 ----- .../Helpers/TestIds.cs | 5 + 129 files changed, 3515 insertions(+), 957 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs rename src/CodeBeam.UltimateAuth.Core/Contracts/Token/{RefreshToken.cs => RefreshTokenInfo.cs} (93%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/AuthSessionIdJsonConverter.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/SessionChainIdJsonConverter.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/SessionRootIdJsonConverter.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/TenantKeyJsonConverter.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/UAuthUserIdConverter.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/UserKeyJsonConverter.cs (100%) create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/AssemblyVisibility.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.csproj create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RoleMapper.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/UserRoleMapper.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RolePermissionProjection.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/UserRoleProjection.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj rename src/{sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore => persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions}/Infrastructure/AuthSessionIdEfConverter.cs (84%) create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs rename src/{sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore => persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions}/Infrastructure/NullableAuthSessionIdConverter.cs (64%) create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs delete mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/{EntityProjections => Projections}/SessionChainProjection.cs (90%) rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/{EntityProjections => Projections}/SessionProjection.cs (91%) rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/{EntityProjections => Projections}/SessionRootProjection.cs (92%) rename src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/{ => Data}/UAuthTokenDbContext.cs (50%) delete mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs rename src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/{ => Extensions}/ServiceCollectionExtensions.cs (72%) create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs delete mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 22c26b0d..7423241a 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -14,6 +14,7 @@ + @@ -26,6 +27,7 @@ + @@ -33,6 +35,7 @@ + diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs index cf43b46a..fb8693c1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -1,23 +1,22 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Abstractions; -/// -/// Low-level persistence abstraction for refresh tokens. -/// NO validation logic. NO business rules. -/// public interface IRefreshTokenStore { - Task StoreAsync(TenantKey tenant, StoredRefreshToken token, CancellationToken ct = default); + Task ExecuteAsync(Func action, CancellationToken ct = default); - Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default); + Task ExecuteAsync(Func> action, CancellationToken ct = default); - Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); + Task StoreAsync(RefreshToken token, CancellationToken ct = default); - Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task FindByHashAsync(string tokenHash, CancellationToken ct = default); - Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); - Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, 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/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index 768f9e27..2f9b3da7 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -2,4 +2,8 @@ [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.Tests.Unit")] diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index e526683e..7fa62828 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections; @@ -29,7 +30,7 @@ public sealed class AccessContext public UserKey GetTargetUserKey() { if (TargetUserKey is not UserKey targetUserKey) - throw new InvalidOperationException("Target user is not found."); + throw new UAuthNotFoundException("Target user is not found."); return targetUserKey; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs index 93c84a09..f9d3b280 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -7,7 +7,7 @@ public sealed record LoginResult public LoginStatus Status { get; init; } public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } public LoginContinuation? Continuation { get; init; } public AuthFailureReason? FailureReason { get; init; } public DateTimeOffset? LockoutUntilUtc { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs index 51b81396..59b18af1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs @@ -9,7 +9,7 @@ public sealed class RefreshFlowResult public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } public static RefreshFlowResult ReauthRequired() { @@ -24,7 +24,7 @@ public static RefreshFlowResult Success( RefreshOutcome outcome, AuthSessionId? sessionId = null, AccessToken? accessToken = null, - RefreshToken? refreshToken = null) + RefreshTokenInfo? refreshToken = null) { return new RefreshFlowResult { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs index be61e290..1b13c185 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs @@ -12,5 +12,5 @@ public sealed record AuthTokens ///
public AccessToken AccessToken { get; init; } = default!; - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs index d741b858..6d5648b5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs @@ -3,7 +3,7 @@ /// /// Transport model for refresh token. Returned to client once upon creation. /// -public sealed class RefreshToken +public sealed class RefreshTokenInfo { /// /// Plain refresh token value (returned to client once). diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs index b1c50d0d..af715f12 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs @@ -10,7 +10,7 @@ public sealed record RefreshTokenRotationResult public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } private RefreshTokenRotationResult() { } @@ -18,7 +18,7 @@ private RefreshTokenRotationResult() { } public static RefreshTokenRotationResult Success( AccessToken accessToken, - RefreshToken refreshToken) + RefreshTokenInfo refreshToken) => new() { IsSuccess = true, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs index 23372748..f3d3049c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -38,11 +38,11 @@ public static DeviceContext Anonymous() public static DeviceContext Create( DeviceId deviceId, - string? deviceType, - string? platform, - string? operatingSystem, - string? browser, - string? ipAddress) + string? deviceType = null, + string? platform = null, + string? operatingSystem = null, + string? browser = null, + string? ipAddress = null) { return new DeviceContext( deviceId, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 806a9c5b..b9351524 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,9 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -// TODO: Add ISoftDeleteable public sealed class UAuthSession : IVersionedEntity { public AuthSessionId SessionId { get; } @@ -12,13 +12,15 @@ public sealed class UAuthSession : IVersionedEntity public SessionChainId ChainId { get; } public DateTimeOffset CreatedAt { get; } public DateTimeOffset ExpiresAt { get; } - public bool IsRevoked { 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, @@ -26,9 +28,9 @@ private UAuthSession( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, + DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata, long version) @@ -39,9 +41,9 @@ private UAuthSession( ChainId = chainId; CreatedAt = createdAt; ExpiresAt = expiresAt; - IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; + Device = device; Claims = claims; Metadata = metadata; Version = version; @@ -55,6 +57,7 @@ public static UAuthSession Create( DateTimeOffset now, DateTimeOffset expiresAt, long securityVersion, + DeviceContext device, ClaimsSnapshot? claims, SessionMetadata metadata) { @@ -65,9 +68,9 @@ public static UAuthSession Create( chainId, createdAt: now, expiresAt: expiresAt, - isRevoked: false, revokedAt: null, securityVersionAtCreation: securityVersion, + device: device, claims: claims ?? ClaimsSnapshot.Empty, metadata: metadata, version: 0 @@ -76,7 +79,8 @@ public static UAuthSession Create( public UAuthSession Revoke(DateTimeOffset at) { - if (IsRevoked) return this; + if (IsRevoked) + return this; return new UAuthSession( SessionId, @@ -85,9 +89,9 @@ public UAuthSession Revoke(DateTimeOffset at) ChainId, CreatedAt, ExpiresAt, - true, at, SecurityVersionAtCreation, + Device, Claims, Metadata, Version + 1 @@ -101,9 +105,9 @@ internal static UAuthSession FromProjection( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, + DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata, long version) @@ -115,9 +119,9 @@ internal static UAuthSession FromProjection( chainId, createdAt, expiresAt, - isRevoked, revokedAt, securityVersionAtCreation, + device, claims, metadata, version @@ -138,7 +142,7 @@ public SessionState GetState(DateTimeOffset at) public UAuthSession WithChain(SessionChainId chainId) { if (!ChainId.IsUnassigned) - throw new InvalidOperationException("Chain already assigned."); + throw new UAuthConflictException("Chain already assigned."); return new UAuthSession( sessionId: SessionId, @@ -147,9 +151,9 @@ public UAuthSession WithChain(SessionChainId chainId) chainId: chainId, createdAt: CreatedAt, expiresAt: ExpiresAt, - isRevoked: IsRevoked, 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 index 7d9139cc..e211cf04 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,6 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Core.Domain; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 0ae69287..d50f9208 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -11,20 +11,19 @@ public sealed class UAuthSessionRoot : IVersionedEntity public DateTimeOffset CreatedAt { get; } public DateTimeOffset? UpdatedAt { get; } - - public bool IsRevoked { 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, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, long version) @@ -34,7 +33,6 @@ private UAuthSessionRoot( UserKey = userKey; CreatedAt = createdAt; UpdatedAt = updatedAt; - IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersion = securityVersion; Version = version; @@ -51,7 +49,6 @@ public static UAuthSessionRoot Create( userKey, at, null, - false, null, 0, 0 @@ -66,7 +63,6 @@ public UAuthSessionRoot IncreaseSecurityVersion(DateTimeOffset at) UserKey, CreatedAt, at, - IsRevoked, RevokedAt, SecurityVersion + 1, Version + 1 @@ -84,7 +80,6 @@ public UAuthSessionRoot Revoke(DateTimeOffset at) UserKey, CreatedAt, at, - true, at, SecurityVersion + 1, Version + 1 @@ -97,7 +92,6 @@ internal static UAuthSessionRoot FromProjection( UserKey userKey, DateTimeOffset createdAt, DateTimeOffset? updatedAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, long version) @@ -108,7 +102,6 @@ internal static UAuthSessionRoot FromProjection( userKey, createdAt, updatedAt, - isRevoked, 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/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs deleted file mode 100644 index bd21a678..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using System.ComponentModel.DataAnnotations.Schema; - -namespace CodeBeam.UltimateAuth.Core.Domain; - -/// -/// Represents a persisted refresh token bound to a session. -/// Stored as a hashed value for security reasons. -/// -public sealed record StoredRefreshToken : IVersionedEntity -{ - public string TokenHash { get; init; } = default!; - - public TenantKey Tenant { get; init; } - - public required UserKey UserKey { get; init; } - - public AuthSessionId SessionId { get; init; } = default!; - public SessionChainId? ChainId { get; init; } - - public DateTimeOffset IssuedAt { get; init; } - public DateTimeOffset ExpiresAt { get; init; } - public DateTimeOffset? RevokedAt { get; init; } - - public string? ReplacedByTokenHash { get; init; } - - public long Version { get; set; } - - [NotMapped] - public bool IsRevoked => RevokedAt.HasValue; - - public bool IsExpired(DateTimeOffset now) => ExpiresAt <= now; - - public bool IsActive(DateTimeOffset now) => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; -} 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/Infrastructure/AuthSessionIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthSessionIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionRootIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionRootIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/TenantKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/TenantKeyJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs 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/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs index 53ca29b6..349dd8ab 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs @@ -5,19 +5,20 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; public sealed class UAuthRefreshTokenValidator : IRefreshTokenValidator { - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenHasher _hasher; - public UAuthRefreshTokenValidator(IRefreshTokenStore store, ITokenHasher hasher) + public UAuthRefreshTokenValidator(IRefreshTokenStoreFactory storeFactory, ITokenHasher hasher) { - _store = store; + _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(context.Tenant, hash, ct); + var stored = await store.FindByHashAsync(hash, ct); if (stored is null) return RefreshTokenValidationResult.Invalid(); @@ -31,7 +32,7 @@ public async Task ValidateAsync(RefreshTokenValida if (stored.IsExpired(context.Now)) { - await _store.RevokeAsync(context.Tenant, hash, context.Now, null, ct); + await store.RevokeAsync(hash, context.Now, null, ct); return RefreshTokenValidationResult.Invalid(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index b364a558..51be3f4b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -8,5 +8,5 @@ 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, RefreshToken refreshToken); + void Write(HttpContext context, GrantKind kind, RefreshTokenInfo refreshToken); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs index 9bb61132..9b525f5f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -10,5 +10,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstactions; public interface ITokenIssuer { Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 694def16..e3b1ba17 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -21,8 +21,7 @@ namespace CodeBeam.UltimateAuth.Server.Flows; internal sealed class LoginOrchestrator : ILoginOrchestrator { private readonly ILoginIdentifierResolver _identifierResolver; - private readonly ICredentialStore _credentialStore; // authentication - private readonly ICredentialValidator _credentialValidator; + private readonly IEnumerable _credentialProviders; // authentication private readonly IUserRuntimeStateProvider _users; // eligible private readonly ILoginAuthority _authority; private readonly ISessionOrchestrator _sessionOrchestrator; @@ -35,8 +34,7 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator public LoginOrchestrator( ILoginIdentifierResolver identifierResolver, - ICredentialStore credentialStore, - ICredentialValidator credentialValidator, + IEnumerable credentialProviders, IUserRuntimeStateProvider users, ILoginAuthority authority, ISessionOrchestrator sessionOrchestrator, @@ -48,8 +46,7 @@ public LoginOrchestrator( IOptions options) { _identifierResolver = identifierResolver; - _credentialStore = credentialStore; - _credentialValidator = credentialValidator; + _credentialProviders = credentialProviders; _users = users; _authority = authority; _sessionOrchestrator = sessionOrchestrator; @@ -99,21 +96,24 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Failed(AuthFailureReason.LockedOut, factorState.LockedUntil, 0); } - var credentials = await _credentialStore.GetByUserAsync(request.Tenant, userKey.Value, ct); - - // TODO: Add .Where(c => c.Type == request.Factor) when we support multiple factors per user - foreach (var credential in credentials.OfType()) + foreach (var provider in _credentialProviders) { - if (!credential.Security.IsUsable(now)) - continue; - - var result = await _credentialValidator.ValidateAsync((ICredential)credential, request.Secret, ct); + var credentials = await provider.GetByUserAsync(request.Tenant, userKey.Value, ct); - if (result.IsValid) + foreach (var credential in credentials) { - credentialsValid = true; - break; + if (credential.IsDeleted || !credential.Security.IsUsable(now)) + continue; + + if (await provider.ValidateAsync(credential, request.Secret, ct)) + { + credentialsValid = true; + break; + } } + + if (credentialsValid) + break; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index 6c43139c..9af510d5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -33,7 +33,7 @@ public void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId) public void Write(HttpContext context, GrantKind kind, AccessToken token) => WriteInternal(context, kind, token.Token); - public void Write(HttpContext context, GrantKind kind, RefreshToken token) + public void Write(HttpContext context, GrantKind kind, RefreshTokenInfo token) => WriteInternal(context, kind, token.Token); public void WriteInternal(HttpContext context, GrantKind kind, string value) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index dc9ce475..4f1d4909 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -117,6 +117,7 @@ await kernel.ExecuteAsync(async _ => now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, + device: context.Device, claims: context.Claims, metadata: context.Metadata ); @@ -193,6 +194,7 @@ await kernel.ExecuteAsync(async _ => now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, + device: context.Device, claims: context.Claims, metadata: context.Metadata ); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 16cd8025..cb0b5955 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -18,15 +18,15 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; - private readonly IRefreshTokenStore _refreshTokenStore; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore, IClock clock) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStoreFactory storeFactory, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; - _refreshTokenStore = refreshTokenStore; + _storeFactory = storeFactory; _clock = clock; } @@ -50,36 +50,37 @@ UAuthMode.SemiHybrid or }; } - public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) + public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { if (flow.EffectiveMode == UAuthMode.PureOpaque) return null; - var expires = _clock.UtcNow.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); + 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); - if (context.SessionId is not AuthSessionId sessionId) - return null; - - var stored = new StoredRefreshToken - { - Tenant = flow.Tenant, - TokenHash = hash, - UserKey = context.UserKey, - SessionId = sessionId, - ChainId = context.ChainId, - IssuedAt = _clock.UtcNow, - ExpiresAt = expires - }; + 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) { - await _refreshTokenStore.StoreAsync(flow.Tenant, stored, ct); + var store = _storeFactory.Create(flow.Tenant); + await store.StoreAsync(stored, ct); } - return new RefreshToken + return new RefreshTokenInfo { Token = raw, TokenHash = hash, diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 73222294..92f0e6d6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,6 +1,7 @@ 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; @@ -10,14 +11,14 @@ namespace CodeBeam.UltimateAuth.Server.Services; public sealed class RefreshTokenRotationService : IRefreshTokenRotationService { private readonly IRefreshTokenValidator _validator; - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenIssuer _tokenIssuer; private readonly IClock _clock; - public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStore store, ITokenIssuer tokenIssuer, IClock clock) + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IClock clock) { _validator = validator; - _store = store; + _storeFactory = storeFactory; _tokenIssuer = tokenIssuer; _clock = clock; } @@ -39,34 +40,30 @@ public async Task RotateAsync(AuthFlowContext flo 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.Tenant, validation.ChainId.Value, context.Now, ct); + await store.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct); } else if (validation.SessionId is not null) { - await _store.RevokeBySessionAsync(validation.Tenant, validation.SessionId.Value, context.Now, ct); + await store.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct); } return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; } - if (validation.UserKey is not UserKey uKey) - { - throw new InvalidOperationException("Validated refresh token does not contain a UserKey."); - } + 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 InvalidOperationException("Validated refresh token does not contain a SessionId."); - } + throw new UAuthValidationException("Validated refresh token does not contain a SessionId."); if (validation.TokenHash == null) - { - throw new InvalidOperationException("Validated refresh token does not contain a hashed token."); - } + throw new UAuthValidationException("Validated refresh token does not contain a hashed token."); var tokenContext = new TokenIssuanceContext { @@ -74,7 +71,7 @@ public async Task RotateAsync(AuthFlowContext flo ? validation.Tenant : TenantKey.Single, - UserKey = uKey, + UserKey = userKey, SessionId = validation.SessionId, ChainId = validation.ChainId }; @@ -89,22 +86,27 @@ public async Task RotateAsync(AuthFlowContext flo }; // Never issue new refresh token before revoke old. Upperline doesn't persist token currently. - // TODO: Add _store.ExecuteAsync here to wrap RevokeAsync and StoreAsync - await _store.RevokeAsync(validation.Tenant, validation.TokenHash, context.Now, refreshToken.TokenHash, ct); - - var stored = new StoredRefreshToken + await store.ExecuteAsync(async ct2 => { - Tenant = flow.Tenant, - TokenHash = refreshToken.TokenHash, - UserKey = uKey, - SessionId = sessionId, - ChainId = validation.ChainId, - IssuedAt = _clock.UtcNow, - ExpiresAt = refreshToken.ExpiresAt - }; - await _store.StoreAsync(validation.Tenant, stored); + 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() + return new RefreshTokenRotationExecution { Tenant = validation.Tenant, UserKey = validation.UserKey, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs index 35626d73..822da088 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs @@ -16,4 +16,6 @@ public static Permission From(string 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.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..8b6785ef --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + 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..b2f9780c --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs @@ -0,0 +1,134 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal sealed class UAuthAuthorizationDbContext : DbContext +{ + public DbSet Roles => Set(); + public DbSet RolePermissions => Set(); + public DbSet UserRoles => Set(); + + private readonly TenantContext _tenant; + + public UAuthAuthorizationDbContext(DbContextOptions options, TenantContext tenant) + : base(options) + { + _tenant = tenant; + } + + protected override void OnModelCreating(ModelBuilder b) + { + ConfigureRole(b); + ConfigureRolePermission(b); + ConfigureUserRole(b); + } + + private void ConfigureRole(ModelBuilder b) + { + b.Entity(e => + { + 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) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.Id }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.NormalizedName }).IsUnique(); + + e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + }); + } + + private void ConfigureRolePermission(ModelBuilder b) + { + b.Entity(e => + { + 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 }); + + e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + }); + } + + private void ConfigureUserRole(ModelBuilder b) + { + b.Entity(e => + { + 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) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.RoleId }); + + e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + }); + } +} \ No newline at end of file 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..8a294a7b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreAuthorization(this IServiceCollection services, Action configureDb) + { + services.AddDbContextPool(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..b4805a6a --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +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..571425a7 --- /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; + +internal 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..1c4e155a --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal 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..42f1f186 --- /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; + +internal 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/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs new file mode 100644 index 00000000..4af2dcac --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs @@ -0,0 +1,277 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +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 +{ + private readonly UAuthAuthorizationDbContext _db; + + public EfCoreRoleStore(UAuthAuthorizationDbContext db) + { + _db = db; + } + + public async Task ExistsAsync(RoleKey key, CancellationToken ct = default) + { + return await _db.Roles + .AnyAsync(x => + x.Tenant == key.Tenant && + x.Id == key.RoleId, + ct); + } + + public async Task AddAsync(Role role, CancellationToken ct = default) + { + var exists = await _db.Roles + .AnyAsync(x => + x.Tenant == role.Tenant && + x.NormalizedName == role.NormalizedName && + x.DeletedAt == null, + ct); + + if (exists) + throw new UAuthConflictException("role_already_exists"); + + var entity = RoleMapper.ToProjection(role); + + _db.Roles.Add(entity); + + var permissionEntities = role.Permissions + .Select(p => RolePermissionMapper.ToProjection(role.Tenant, role.Id, p)); + + _db.RolePermissions.AddRange(permissionEntities); + + await _db.SaveChangesAsync(ct); + } + + public async Task GetAsync(RoleKey key, CancellationToken ct = default) + { + var entity = await _db.Roles + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Tenant == key.Tenant && + x.Id == key.RoleId, + ct); + + if (entity is null) + return null; + + var permissionEntities = await _db.RolePermissions + .AsNoTracking() + .Where(x => + x.Tenant == key.Tenant && + x.RoleId == key.RoleId) + .ToListAsync(ct); + + return RoleMapper.ToDomain(entity, permissionEntities); + } + + public async Task SaveAsync(Role role, long expectedVersion, CancellationToken ct = default) + { + var entity = await _db.Roles + .SingleOrDefaultAsync(x => + x.Tenant == role.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 _db.Roles + .AnyAsync(x => + x.Tenant == role.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 _db.RolePermissions + .Where(x => + x.Tenant == role.Tenant && + x.RoleId == role.Id) + .ToListAsync(ct); + + _db.RolePermissions.RemoveRange(existingPermissions); + var newPermissions = role.Permissions.Select(p => RolePermissionMapper.ToProjection(role.Tenant, role.Id, p)); + _db.RolePermissions.AddRange(newPermissions); + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(RoleKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var entity = await _db.Roles + .SingleOrDefaultAsync(x => + x.Tenant == key.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) + { + var permissions = await _db.RolePermissions + .Where(x => + x.Tenant == key.Tenant && + x.RoleId == key.RoleId) + .ToListAsync(ct); + + _db.RolePermissions.RemoveRange(permissions); + _db.Roles.Remove(entity); + } + else + { + entity.DeletedAt = now; + entity.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default) + { + var entity = await _db.Roles + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Tenant == tenant && + x.NormalizedName == normalizedName && + x.DeletedAt == null, + ct); + + if (entity is null) + return null; + + var permissionEntities = await _db.RolePermissions + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.RoleId == entity.Id) + .ToListAsync(ct); + + return RoleMapper.ToDomain(entity, permissionEntities); + } + + public async Task> GetByIdsAsync( + TenantKey tenant, + IReadOnlyCollection roleIds, + CancellationToken ct = default) + { + var entities = await _db.Roles + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + roleIds.Contains(x.Id)) + .ToListAsync(ct); + + var roleIdsSet = entities.Select(x => x.Id).ToList(); + + var permissions = await _db.RolePermissions + .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( + TenantKey tenant, + RoleQuery query, + CancellationToken ct = default) + { + var normalized = query.Normalize(); + + var baseQuery = _db.Roles + .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/EfCoreUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs new file mode 100644 index 00000000..c9db3992 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs @@ -0,0 +1,93 @@ +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 +{ + private readonly UAuthAuthorizationDbContext _db; + + public EfCoreUserRoleStore(UAuthAuthorizationDbContext db) + { + _db = db; + } + + public async Task> GetAssignmentsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var entities = await _db.UserRoles + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey) + .ToListAsync(ct); + + return entities.Select(UserRoleMapper.ToDomain).ToList().AsReadOnly(); + } + + public async Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) + { + var exists = await _db.UserRoles + .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 + }; + + _db.UserRoles.Add(entity); + await _db.SaveChangesAsync(ct); + } + + public async Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default) + { + var entity = await _db.UserRoles + .SingleOrDefaultAsync(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.RoleId == roleId, + ct); + + if (entity is null) + return; + + _db.UserRoles.Remove(entity); + await _db.SaveChangesAsync(ct); + } + + public async Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) + { + var entities = await _db.UserRoles + .Where(x => + x.Tenant == tenant && + x.RoleId == roleId) + .ToListAsync(ct); + + if (entities.Count == 0) + return; + + _db.UserRoles.RemoveRange(entities); + await _db.SaveChangesAsync(ct); + } + + public async Task CountAssignmentsAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) + { + return await _db.UserRoles + .CountAsync(x => + x.Tenant == tenant && + x.RoleId == roleId, + ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 5d987173..21f50c7b 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -2,6 +2,7 @@ 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; @@ -33,7 +34,7 @@ public async Task AssignAsync(AccessContext context, UserKey targetUserKey, stri var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); if (role is null || role.IsDeleted) - throw new InvalidOperationException("role_not_found"); + throw new UAuthNotFoundException("role_not_found"); await _userRoles.AssignAsync(context.ResourceTenant, targetUserKey, role.Id, now, innerCt); }); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs index b008663c..f48a15e9 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; @@ -35,7 +36,7 @@ public static Role Create( DateTimeOffset now) { if (string.IsNullOrWhiteSpace(name)) - throw new InvalidOperationException("role_name_required"); + throw new UAuthValidationException("role_name_required"); var normalized = Normalize(name); @@ -61,7 +62,7 @@ public static Role Create( public Role Rename(string newName, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(newName)) - throw new InvalidOperationException("role_name_required"); + throw new UAuthValidationException("role_name_required"); if (NormalizedName == Normalize(newName)) return this; @@ -130,6 +131,34 @@ public Role Snapshot() 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/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index 27436d4a..04edc296 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -9,7 +9,7 @@ public sealed class CredentialSecurityState public Guid SecurityStamp { get; } public bool IsRevoked => RevokedAt != null; - public bool IsExpired => ExpiresAt != null; + public bool IsExpired(DateTimeOffset now) => ExpiresAt != null && ExpiresAt <= now; public CredentialSecurityState( DateTimeOffset? revokedAt = null, @@ -26,7 +26,7 @@ public CredentialSecurityStatus Status(DateTimeOffset now) if (RevokedAt is not null) return CredentialSecurityStatus.Revoked; - if (ExpiresAt is not null && ExpiresAt <= now) + if (IsExpired(now)) return CredentialSecurityStatus.Expired; return CredentialSecurityStatus.Active; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs deleted file mode 100644 index 5b8773ff..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal sealed class CredentialUserMapping -{ - public Func UserId { get; init; } = default!; - public Func Username { get; init; } = default!; - public Func PasswordHash { get; init; } = default!; - public Func SecurityVersion { get; init; } = default!; - public Func CanAuthenticate { get; init; } = default!; -} 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 index 59c525b9..45d9f8fb 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -9,22 +9,10 @@ true $(NoWarn);1591 - - - - - - - - - - - - - + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs deleted file mode 100644 index 08378ed6..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Linq.Expressions; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal static class ConventionResolver -{ - public static Expression>? TryResolve(params string[] names) - { - var prop = typeof(TUser) - .GetProperties() - .FirstOrDefault(p => - names.Contains(p.Name, StringComparer.OrdinalIgnoreCase) && - typeof(TProp).IsAssignableFrom(p.PropertyType)); - - if (prop is null) - return null; - - var param = Expression.Parameter(typeof(TUser), "u"); - var body = Expression.Property(param, prop); - - return Expression.Lambda>(body, param); - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs deleted file mode 100644 index 4baa14c7..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal static class CredentialUserMappingBuilder -{ - public static CredentialUserMapping Build(CredentialUserMappingOptions options) - { - if (options.UserId is null) - { - var expr = ConventionResolver.TryResolve("Id", "UserId"); - if (expr != null) - options.ApplyUserId(expr); - } - - if (options.Username is null) - { - var expr = ConventionResolver.TryResolve( - "Username", - "UserName", - "Email", - "EmailAddress", - "Login"); - - if (expr != null) - options.ApplyUsername(expr); - } - - // Never add "Password" as a convention to avoid accidental mapping to plaintext password properties - if (options.PasswordHash is null) - { - var expr = ConventionResolver.TryResolve( - "PasswordHash", - "Passwordhash", - "PasswordHashV2"); - - if (expr != null) - options.ApplyPasswordHash(expr); - } - - if (options.SecurityVersion is null) - { - var expr = ConventionResolver.TryResolve( - "SecurityVersion", - "SecurityStamp", - "AuthVersion"); - - if (expr != null) - options.ApplySecurityVersion(expr); - } - - - if (options.UserId is null) - throw new InvalidOperationException("UserId mapping is required. Use MapUserId(...) or ensure a conventional property exists."); - - if (options.Username is null) - throw new InvalidOperationException("Username mapping is required. Use MapUsername(...) or ensure a conventional property exists."); - - if (options.PasswordHash is null) - throw new InvalidOperationException("PasswordHash mapping is required. Use MapPasswordHash(...) or ensure a conventional property exists."); - - if (options.SecurityVersion is null) - throw new InvalidOperationException("SecurityVersion mapping is required. Use MapSecurityVersion(...) or ensure a conventional property exists."); - - var canAuthenticateExpr = options.CanAuthenticate ?? (_ => true); - - return new CredentialUserMapping - { - UserId = options.UserId.Compile(), - Username = options.Username.Compile(), - PasswordHash = options.PasswordHash.Compile(), - SecurityVersion = options.SecurityVersion.Compile(), - CanAuthenticate = canAuthenticateExpr.Compile() - }; - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs deleted file mode 100644 index b8c326fc..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Linq.Expressions; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -public sealed class CredentialUserMappingOptions -{ - internal Expression>? UserId { get; private set; } - internal Expression>? Username { get; private set; } - internal Expression>? PasswordHash { get; private set; } - internal Expression>? SecurityVersion { get; private set; } - internal Expression>? CanAuthenticate { get; private set; } - - public void MapUserId(Expression> expr) => UserId = expr; - public void MapUsername(Expression> expr) => Username = expr; - public void MapPasswordHash(Expression> expr) => PasswordHash = expr; - public void MapSecurityVersion(Expression> expr) => SecurityVersion = expr; - - /// - /// Optional. If not specified, all users are allowed to authenticate. - /// Use this to enforce custom user state rules (e.g. Active, Locked, Suspended). - /// Users that can't authenticate don't show up in authentication results. - /// - public void MapCanAuthenticate(Expression> expr) => CanAuthenticate = expr; - - internal void ApplyUserId(Expression> expr) => UserId = expr; - internal void ApplyUsername(Expression> expr) => Username = expr; - internal void ApplyPasswordHash(Expression> expr) => PasswordHash = expr; - internal void ApplySecurityVersion(Expression> expr) => SecurityVersion = expr; -} 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..ed530b36 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs @@ -0,0 +1,70 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class UAuthCredentialDbContext : DbContext +{ + public DbSet PasswordCredentials => Set(); + + private readonly TenantContext _tenant; + + public UAuthCredentialDbContext(DbContextOptions options, TenantContext tenant) + : base(options) + { + _tenant = tenant; + } + + protected override void OnModelCreating(ModelBuilder b) + { + ConfigurePasswordCredential(b); + } + + private void ConfigurePasswordCredential(ModelBuilder b) + { + b.Entity(e => + { + 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.RevokedAt); + e.Property(x => x.ExpiresAt); + e.Property(x => x.LastUsedAt); + e.Property(x => x.Source).HasMaxLength(128); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.UpdatedAt); + e.Property(x => x.DeletedAt); + + 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 }); + + e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + }); + } +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs deleted file mode 100644 index 61a8b904..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal sealed class EfCoreAuthUser : IAuthSubject -{ - public TUserId UserId { get; } - - IReadOnlyDictionary? IAuthSubject.Claims => null; - - public EfCoreAuthUser(TUserId userId) - { - UserId = userId; - } -} 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..e235a2a1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreCredentials(this IServiceCollection services, Action configureDb) + { + services.AddDbContextPool(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..8fbf3854 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs @@ -0,0 +1,67 @@ +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.Create( + id: p.Id, + tenant: p.Tenant, + userKey: p.UserKey, + secretHash: p.SecretHash, + security: security, + metadata: metadata, + now: p.CreatedAt + ); + } + 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; + } +} \ 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..d25e8edc --- /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; + +internal 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/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs deleted file mode 100644 index 97442ecb..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddUltimateAuthEfCoreCredentials(this IServiceCollection services, Action> configure) where TUser : class - { - services.Configure(configure); - - return services; - } -} 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..ae1fe5f0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs @@ -0,0 +1,146 @@ +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 +{ + private readonly UAuthCredentialDbContext _db; + private readonly TenantContext _tenant; + + public EfCorePasswordCredentialStore(UAuthCredentialDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public async Task ExistsAsync(CredentialKey key, CancellationToken ct = default) + { + return await _db.PasswordCredentials + .AnyAsync(x => + x.Id == key.Id && + x.Tenant == key.Tenant, + ct); + } + + public async Task AddAsync(PasswordCredential credential, CancellationToken ct = default) + { + var entity = credential.ToProjection(); + _db.PasswordCredentials.Add(entity); + await _db.SaveChangesAsync(ct); + } + + public async Task GetAsync(CredentialKey key, CancellationToken ct = default) + { + var entity = await _db.PasswordCredentials + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Id == key.Id && + x.Tenant == key.Tenant, + ct); + + return entity?.ToDomain(); + } + + public async Task SaveAsync(PasswordCredential credential, long expectedVersion, CancellationToken ct = default) + { + var entity = await _db.PasswordCredentials + .SingleOrDefaultAsync(x => + x.Id == credential.Id && + x.Tenant == credential.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 credential = await GetAsync(key, ct); + + if (credential is null) + throw new UAuthNotFoundException("credential_not_found"); + + var revoked = credential.Revoke(revokedAt); + await SaveAsync(revoked, expectedVersion, ct); + } + + public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var entity = await _db.PasswordCredentials + .SingleOrDefaultAsync(x => + x.Id == key.Id && + x.Tenant == key.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) + { + _db.PasswordCredentials.Remove(entity); + } + else + { + entity.DeletedAt = now; + entity.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var entities = await _db.PasswordCredentials + .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(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var entities = await _db.PasswordCredentials + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey) + .ToListAsync(ct); + + foreach (var entity in entities) + { + if (mode == DeleteMode.Hard) + { + _db.PasswordCredentials.Remove(entity); + } + else + { + entity.DeletedAt = now; + entity.Version++; + } + } + + await _db.SaveChangesAsync(ct); + } +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index e4ef841f..ed3e2ade 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -14,12 +14,12 @@ internal sealed class InMemoryCredentialSeedContributor : ISeedContributor private static readonly Guid _userPasswordId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); public int Order => 10; - private readonly ICredentialStore _credentials; + private readonly IPasswordCredentialStore _credentials; private readonly IInMemoryUserIdProvider _ids; private readonly IUAuthPasswordHasher _hasher; private readonly IClock _clock; - public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) + public InMemoryCredentialSeedContributor(IPasswordCredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) { _credentials = credentials; _ids = ids; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs deleted file mode 100644 index 65d6f39c..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ /dev/null @@ -1,87 +0,0 @@ -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; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory; - -internal sealed class InMemoryCredentialStore : InMemoryVersionedStore, ICredentialStore -{ - protected override CredentialKey GetKey(PasswordCredential entity) => new(entity.Tenant, entity.Id); - - public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var result = Values() - .Where(c => c.Tenant == tenant && c.UserKey == userKey) - .Cast() - .ToArray(); - - return Task.FromResult>(result); - } - - public Task GetByIdAsync(CredentialKey key, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (TryGet(key, out var entity)) - return Task.FromResult(entity); - - return Task.FromResult(entity); - } - - public Task AddAsync(ICredential credential, CancellationToken ct = default) - { - // TODO: Implement other credential types - if (credential is not PasswordCredential pwd) - throw new NotSupportedException("Only password credentials are supported in-memory."); - - return base.AddAsync(pwd, ct); - } - - public Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default) - { - if (credential is not PasswordCredential pwd) - throw new NotSupportedException("Only password credentials are supported in-memory."); - - return base.SaveAsync(pwd, expectedVersion, ct); - } - - public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (!TryGet(key, out var credential)) - throw new UAuthNotFoundException("credential_not_found"); - - if (credential is not PasswordCredential pwd) - throw new NotSupportedException("Only password credentials are supported in-memory."); - - var revoked = pwd.Revoke(revokedAt); - - return SaveAsync(revoked, expectedVersion, ct); - } - - public Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default) - { - return base.DeleteAsync(key, expectedVersion, mode, now, ct); - } - - public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var credentials = Values() - .Where(c => c.Tenant == tenant && c.UserKey == userKey) - .ToList(); - - foreach (var credential in credentials) - { - await DeleteAsync(new CredentialKey(tenant, credential.Id), mode, now, credential.Version, ct); - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs deleted file mode 100644 index 37be6cb4..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory; - -internal sealed class InMemoryPasswordCredentialState -{ - public UserKey UserKey { get; init; } = default!; - public CredentialType Type { get; } = CredentialType.Password; - - public string SecretHash { get; set; } = default!; - - public CredentialSecurityState Security { get; set; } = default!; - public CredentialMetadata Metadata { get; set; } = default!; -} 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..61f22f38 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs @@ -0,0 +1,64 @@ +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; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +internal sealed class InMemoryPasswordCredentialStore : InMemoryVersionedStore, IPasswordCredentialStore +{ + protected override CredentialKey GetKey(PasswordCredential entity) + => new(entity.Tenant, entity.Id); + + protected override void BeforeAdd(PasswordCredential entity) + { + var exists = Values() + .Any(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + !x.IsDeleted); + + if (exists) + throw new UAuthConflictException("password_credential_exists"); + } + + public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = Values() + .Where(x => + x.Tenant == tenant && + 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(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var credentials = Values() + .Where(c => c.Tenant == tenant && c.UserKey == userKey) + .ToList(); + + foreach (var credential in credentials) + { + await DeleteAsync(new CredentialKey(tenant, credential.Id), credential.Version, mode, now, ct); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs index c078aca1..86118255 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Reference; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -8,8 +9,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddSingleton(); // Never try add seed services.AddSingleton(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 9650be6c..ee5c9c64 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -6,13 +6,14 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor, IVersionedEntity, IEntitySnapshot, ISoftDeletable +public sealed class PasswordCredential : ISecretCredential, 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(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index 12497321..2ee133b7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -14,7 +14,7 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - + services.AddScoped(); return services; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs new file mode 100644 index 00000000..6bdb1d05 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs @@ -0,0 +1,14 @@ +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; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordCredentialStore : IVersionedStore +{ + Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task DeleteByUserAsync(TenantKey tenant, 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/Infrastructure/PasswordCredentialProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs new file mode 100644 index 00000000..cfe691bb --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class PasswordCredentialProvider : ICredentialProvider +{ + private readonly IPasswordCredentialStore _store; + private readonly ICredentialValidator _validator; + + public CredentialType Type => CredentialType.Password; + + public PasswordCredentialProvider(IPasswordCredentialStore store, ICredentialValidator validator) + { + _store = store; + _validator = validator; + } + + public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var creds = await _store.GetByUserAsync(tenant, 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 index efd94263..039559f7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -10,11 +10,11 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegration { - private readonly ICredentialStore _credentialStore; + private readonly IPasswordCredentialStore _credentialStore; private readonly IUAuthPasswordHasher _passwordHasher; private readonly IClock _clock; - public PasswordUserLifecycleIntegration(ICredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) + public PasswordUserLifecycleIntegration(IPasswordCredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) { _credentialStore = credentialStore; _passwordHasher = passwordHasher; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index eaaa8a65..df782936 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -17,7 +17,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class CredentialManagementService : ICredentialManagementService, IUserCredentialsInternalService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly ICredentialStore _credentials; + private readonly IPasswordCredentialStore _credentials; private readonly IAuthenticationSecurityManager _authenticationSecurityManager; private readonly IOpaqueTokenGenerator _tokenGenerator; private readonly INumericCodeGenerator _numericCodeGenerator; @@ -30,7 +30,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService public CredentialManagementService( IAccessOrchestrator accessOrchestrator, - ICredentialStore credentials, + IPasswordCredentialStore credentials, IAuthenticationSecurityManager authenticationSecurityManager, IOpaqueTokenGenerator tokenGenerator, INumericCodeGenerator numericCodeGenerator, @@ -66,7 +66,6 @@ public async Task GetAllAsync(AccessContext context, Cance var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); var dtos = credentials - .OfType() .Select(c => new CredentialInfo { Id = c.Id, @@ -174,7 +173,7 @@ public async Task RevokeAsync(AccessContext context, Rev var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + var credential = await _credentials.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); if (credential is not PasswordCredential pwd) return CredentialActionResult.Fail("credential_not_found"); @@ -352,7 +351,7 @@ public async Task DeleteAsync(AccessContext context, Del var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + var credential = await _credentials.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); if (credential is not PasswordCredential pwd) return CredentialActionResult.Fail("credential_not_found"); @@ -361,7 +360,7 @@ public async Task DeleteAsync(AccessContext context, Del return CredentialActionResult.Fail("credential_not_found"); var oldVersion = pwd.Version; - await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), request.Mode, now, oldVersion, innerCt); + await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), oldVersion, request.Mode, now, innerCt); return CredentialActionResult.Success(); }); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs index a9cdc74b..71b81e12 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -1,12 +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; init; } - UserKey UserKey { get; init; } + 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/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs deleted file mode 100644 index bc513d3a..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials; - -public interface ICredentialDescriptor -{ - Guid Id { get; } - CredentialType Type { get; } - CredentialSecurityState Security { get; } - CredentialMetadata Metadata { get; } - long Version { 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/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs index 1e2f3b86..36daf785 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -1,17 +1,18 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; +//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; -namespace CodeBeam.UltimateAuth.Credentials; +//namespace CodeBeam.UltimateAuth.Credentials; -public interface ICredentialStore -{ - Task>GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task GetByIdAsync(CredentialKey key, CancellationToken ct = default); - Task AddAsync(ICredential credential, CancellationToken ct = default); - Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default); - Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); - Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default); - Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); -} +//public interface ICredentialStore : IVersionedStore +//{ +// Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + +// Task GetByIdAsync(CredentialKey key, CancellationToken ct = default); + +// Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); + +// Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); +//} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs index ba61b1a8..e55164ab 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs @@ -18,7 +18,7 @@ public Task ValidateAsync(ICredential credential, st { ct.ThrowIfCancellationRequested(); - if (credential is ICredentialDescriptor securable) + if (credential is ICredential securable) { if (!securable.Security.IsUsable(_clock.UtcNow)) { diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj new file mode 100644 index 00000000..4c065564 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs similarity index 84% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs index 60c6a5b1..dd6adbd5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; internal static class AuthSessionIdEfConverter { @@ -14,8 +14,7 @@ public static AuthSessionId FromDatabase(string raw) return id; } - public static string ToDatabase(AuthSessionId id) - => id.Value; + public static string ToDatabase(AuthSessionId id) => id.Value; public static AuthSessionId? FromDatabaseNullable(string? raw) { diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs new file mode 100644 index 00000000..52bb84c2 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/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.Abstractions/Infrastructure/JsonValueComparers.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs new file mode 100644 index 00000000..829e1700 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/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.Abstractions/Infrastructure/JsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs new file mode 100644 index 00000000..44d6293d --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/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/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs similarity index 64% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs index 099a69cc..7972f21a 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs @@ -1,16 +1,16 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; -internal sealed class AuthSessionIdConverter : ValueConverter +public sealed class AuthSessionIdConverter : ValueConverter { public AuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabase(id), raw => AuthSessionIdEfConverter.FromDatabase(raw)) { } } -internal sealed class NullableAuthSessionIdConverter : ValueConverter +public sealed class NullableAuthSessionIdConverter : ValueConverter { public NullableAuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabaseNullable(id), raw => AuthSessionIdEfConverter.FromDatabaseNullable(raw)) { diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs new file mode 100644 index 00000000..f1ac4d05 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/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.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs new file mode 100644 index 00000000..34e22b2e --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/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.Abstractions/Infrastructure/SessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs new file mode 100644 index 00000000..ff21b301 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/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.Abstractions/Infrastructure/SessionChainIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs new file mode 100644 index 00000000..14dc9eae --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/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/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj index e656299b..4cda4e65 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj @@ -8,21 +8,9 @@ $(NoWarn);1591 - - - - - - - - - - - - - + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index b7f0977d..c78491f8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; @@ -34,8 +35,13 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.UserKey) + e.Property(x => x.UserKey).IsRequired(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) .IsRequired(); e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); @@ -48,6 +54,7 @@ protected override void OnModelCreating(ModelBuilder b) .HasConversion( v => v.Value, v => SessionRootId.From(v)) + .HasMaxLength(128) .IsRequired(); }); @@ -56,11 +63,25 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.UserKey) + e.Property(x => x.UserKey).IsRequired(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) .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); e.Property(x => x.ChainId) .HasConversion( @@ -68,6 +89,17 @@ protected override void OnModelCreating(ModelBuilder b) 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()); @@ -83,9 +115,26 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .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); e.Property(x => x.SessionId) .HasConversion(new AuthSessionIdConverter()) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 478a44e6..82bb21d0 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,14 +1,15 @@ -using Microsoft.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb) { - services.AddDbContext(configureDb); - services.AddScoped(); + services.AddDbContextPool(configureDb); + services.AddScoped(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs deleted file mode 100644 index 68fb5ff1..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System.Text.Json; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; - -internal sealed class JsonValueConverter : ValueConverter -{ - public JsonValueConverter() - : base( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!) - { - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 683f9453..271f5c75 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -27,6 +27,9 @@ public static UAuthSessionChain ToDomain(this SessionChainProjection p) 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, @@ -36,6 +39,7 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) CreatedAt = chain.CreatedAt, LastSeenAt = chain.LastSeenAt, AbsoluteExpiresAt = chain.AbsoluteExpiresAt, + DeviceId = deviceId, Device = chain.Device, ClaimsSnapshot = chain.ClaimsSnapshot, ActiveSessionId = chain.ActiveSessionId, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index 9b591691..6377ea28 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -13,9 +13,9 @@ public static UAuthSession ToDomain(this SessionProjection p) p.ChainId, p.CreatedAt, p.ExpiresAt, - p.IsRevoked, p.RevokedAt, p.SecurityVersionAtCreation, + p.Device, p.Claims, p.Metadata, p.Version @@ -33,15 +33,13 @@ public static SessionProjection ToProjection(this UAuthSession s) CreatedAt = s.CreatedAt, ExpiresAt = s.ExpiresAt, - - IsRevoked = s.IsRevoked, RevokedAt = s.RevokedAt, SecurityVersionAtCreation = s.SecurityVersionAtCreation, + Device = s.Device, Claims = s.Claims, Metadata = s.Metadata, Version = s.Version }; } - } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index 63d1fa41..d38a02a5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -12,7 +12,6 @@ public static UAuthSessionRoot ToDomain(this SessionRootProjection root) root.UserKey, root.CreatedAt, root.UpdatedAt, - root.IsRevoked, root.RevokedAt, root.SecurityVersion, root.Version @@ -29,8 +28,6 @@ public static SessionRootProjection ToProjection(this UAuthSessionRoot root) CreatedAt = root.CreatedAt, UpdatedAt = root.UpdatedAt, - - IsRevoked = root.IsRevoked, RevokedAt = root.RevokedAt, SecurityVersion = root.SecurityVersion, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs similarity index 90% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs index 7106b024..ffc23f10 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs @@ -15,7 +15,8 @@ internal sealed class SessionChainProjection public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset LastSeenAt { get; set; } public DateTimeOffset? AbsoluteExpiresAt { get; set; } - public DeviceContext Device { 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; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs similarity index 91% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs index 58c37604..19061c50 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs @@ -15,9 +15,8 @@ internal sealed class SessionProjection public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } - public DateTimeOffset? LastSeenAt { get; set; } - public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } public long SecurityVersionAtCreation { get; set; } @@ -27,4 +26,6 @@ internal sealed class SessionProjection 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/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs similarity index 92% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs index 5d1e613f..4d9c0ff8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs @@ -13,9 +13,10 @@ internal sealed class SessionRootProjection public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; } - public bool IsRevoked { 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/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 65a2a1db..d52c232f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -10,12 +10,10 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; internal sealed class EfCoreSessionStore : ISessionStore { private readonly UltimateAuthSessionDbContext _db; - private readonly TenantContext _tenant; - public EfCoreSessionStore(UltimateAuthSessionDbContext db, TenantContext tenant) + public EfCoreSessionStore(UltimateAuthSessionDbContext db) { _db = db; - _tenant = tenant; } public async Task ExecuteAsync(Func action, CancellationToken ct = default) @@ -24,12 +22,7 @@ public async Task ExecuteAsync(Func action, Cancellatio await strategy.ExecuteAsync(async () => { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); + await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); try { @@ -47,10 +40,6 @@ await strategy.ExecuteAsync(async () => await tx.RollbackAsync(ct); throw; } - finally - { - _db.Database.UseTransaction(null); - } }); } @@ -60,12 +49,7 @@ public async Task ExecuteAsync(Func { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); + await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); try { @@ -74,14 +58,15 @@ public async Task ExecuteAsync(Func ExecuteAsync(Func x.Version).OriginalValue = expectedVersion; - - try - { - await Task.CompletedTask; - } - catch (DbUpdateConcurrencyException) - { - throw new UAuthConcurrencyException("session_concurrency_conflict"); - } + _db.Entry(projection).State = EntityState.Modified; + return Task.CompletedTask; } public async Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) @@ -133,18 +109,13 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs { ct.ThrowIfCancellationRequested(); - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); - - if (projection is null) - return false; + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); - var session = projection.ToDomain(); - if (session.IsRevoked) + if (projection is null || projection.IsRevoked) return false; - var revoked = session.Revoke(at); + var revoked = projection.ToDomain().Revoke(at); _db.Sessions.Update(revoked.ToProjection()); - return true; } @@ -153,29 +124,23 @@ public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, Cancel ct.ThrowIfCancellationRequested(); var chains = await _db.Chains.Where(x => x.UserKey == user).ToListAsync(ct); + var chainIds = chains.Select(x => x.ChainId).ToList(); + var sessions = await _db.Sessions.Where(x => chainIds.Contains(x.ChainId)).ToListAsync(ct); - foreach (var chainProjection in chains) + foreach (var sessionProjection in sessions) { - var chain = chainProjection.ToDomain(); - - var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); - - foreach (var sessionProjection in sessions) - { - var session = sessionProjection.ToDomain(); + var session = sessionProjection.ToDomain(); - if (session.IsRevoked) - continue; + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); + } - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); if (chain.ActiveSessionId is not null) - { - var updatedChain = chain.DetachSession(at); - _db.Chains.Update(updatedChain.ToProjection()); - } + _db.Chains.Update(chain.DetachSession(at).ToProjection()); } } @@ -184,29 +149,23 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai ct.ThrowIfCancellationRequested(); var chains = await _db.Chains.Where(x => x.UserKey == user && x.ChainId != keepChain).ToListAsync(ct); + var chainIds = chains.Select(x => x.ChainId).ToList(); + var sessions = await _db.Sessions.Where(x => chainIds.Contains(x.ChainId)).ToListAsync(ct); - foreach (var chainProjection in chains) + foreach (var sessionProjection in sessions) { - var chain = chainProjection.ToDomain(); - - var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); - - foreach (var sessionProjection in sessions) - { - var session = sessionProjection.ToDomain(); + var session = sessionProjection.ToDomain(); - if (session.IsRevoked) - continue; + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); + } - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); if (chain.ActiveSessionId is not null) - { - var updatedChain = chain.DetachSession(at); - _db.Chains.Update(updatedChain.ToProjection()); - } + _db.Chains.Update(chain.DetachSession(at).ToProjection()); } } @@ -231,7 +190,7 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai x.Tenant == tenant && x.UserKey == userKey && x.RevokedAt == null && - x.Device.DeviceId == deviceId) + x.DeviceId == deviceId) .SingleOrDefaultAsync(ct); return projection?.ToDomain(); @@ -392,19 +351,8 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return rootProjection.ToDomain(); + var rootProjection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.UserKey == userKey, ct); + return rootProjection?.ToDomain(); } public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) @@ -511,19 +459,8 @@ public async Task> GetSessionsByChainAsync(SessionCh { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.RootId == rootId); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.RootId == rootId) - .ToListAsync(); - - return rootProjection.ToDomain(); + var projection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.RootId == rootId, ct); + return projection?.ToDomain(); } public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) 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 index 9f31a13b..29b9bf38 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj @@ -7,22 +7,10 @@ true $(NoWarn);1591 - - - - - - - - - - - - - + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs similarity index 50% rename from src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs rename to src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs index f8c371ea..a7caf53b 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs @@ -1,4 +1,7 @@ -using Microsoft.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; @@ -18,42 +21,44 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.Version).IsConcurrencyToken(); + e.Property(x => x.Version) + .IsConcurrencyToken(); - e.Property(x => x.TokenHash) + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.TokenId) + .HasConversion( + v => v.Value, + v => TokenId.From(v)) .IsRequired(); - e.HasIndex(x => new { x.Tenant, x.TokenHash }) - .IsUnique(); + e.Property(x => x.TokenHash) + .HasMaxLength(128) + .IsRequired(); + 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 }); - e.Property(x => x.ExpiresAt).IsRequired(); - }); + e.Property(x => x.SessionId) + .HasConversion(new AuthSessionIdConverter()); - b.Entity(e => - { - e.HasKey(x => x.Id); - - e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.Jti) - .IsRequired(); - - e.HasIndex(x => x.Jti) - .IsUnique(); - - e.HasIndex(x => new { x.Tenant, x.Jti }); + e.Property(x => x.ChainId) + .HasConversion(new NullableSessionChainIdConverter()); e.Property(x => x.ExpiresAt) .IsRequired(); - - e.Property(x => x.RevokedAt) - .IsRequired(); }); } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs deleted file mode 100644 index f4f95708..00000000 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs +++ /dev/null @@ -1,110 +0,0 @@ -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 -{ - private readonly UltimateAuthTokenDbContext _db; - - public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, IUserIdConverterResolver converters) - { - _db = db; - } - - public async Task StoreAsync(TenantKey tenantId, StoredRefreshToken token, CancellationToken ct = default) - { - if (token.Tenant != tenantId) - throw new InvalidOperationException("TenantId mismatch between context and token."); - - if (token.ChainId is null) - throw new InvalidOperationException("Refresh token must have a ChainId before being stored."); - - _db.RefreshTokens.Add(new RefreshTokenProjection - { - Tenant = tenantId, - TokenHash = token.TokenHash, - UserKey = token.UserKey, - SessionId = token.SessionId, - ChainId = token.ChainId.Value, - IssuedAt = token.IssuedAt, - ExpiresAt = token.ExpiresAt - }); - - await _db.SaveChangesAsync(ct); - } - - public async Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default) - { - var e = await _db.RefreshTokens - .AsNoTracking() - .SingleOrDefaultAsync( - x => x.TokenHash == tokenHash && - x.Tenant == tenant, - ct); - - if (e is null) - return null; - - return new StoredRefreshToken - { - Tenant = e.Tenant, - TokenHash = e.TokenHash, - UserKey = e.UserKey, - SessionId = e.SessionId, - ChainId = e.ChainId, - IssuedAt = e.IssuedAt, - ExpiresAt = e.ExpiresAt, - RevokedAt = e.RevokedAt - }; - } - - public Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) - { - var query = _db.RefreshTokens - .Where(x => - x.TokenHash == tokenHash && - x.Tenant == tenant && - x.RevokedAt == null); - - if (replacedByTokenHash == 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(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) - => _db.RefreshTokens - .Where(x => - x.Tenant == tenant && - x.SessionId == sessionId.Value && - x.RevokedAt == null) - .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - - public Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) - => _db.RefreshTokens - .Where(x => - x.Tenant == tenant && - x.ChainId == chainId && - x.RevokedAt == null) - .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - - public Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) - { - - return _db.RefreshTokens - .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/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs similarity index 72% rename from src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs rename to src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 243d161c..c3dcc34f 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -8,8 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) { - services.AddDbContext(configureDb); - services.AddScoped(typeof(IRefreshTokenStore), typeof(EfCoreRefreshTokenStore)); + services.AddDbContextPool(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 index d1167f36..3f47970e 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -3,21 +3,28 @@ namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; -// Add mapper class if needed (adding domain rules etc.) internal sealed class RefreshTokenProjection { - public long Id { get; set; } // Surrogate PK + 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; } = default!; + + public SessionChainId? ChainId { get; set; } public string? ReplacedByTokenHash { get; set; } - public DateTimeOffset IssuedAt { 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/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs new file mode 100644 index 00000000..7347936e --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -0,0 +1,162 @@ +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 +{ + private readonly UltimateAuthTokenDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, TenantKey tenant) + { + _db = db; + _tenant = tenant; + } + + 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); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + 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(ct); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + }); + } + + public Task StoreAsync(RefreshToken token, CancellationToken ct = default) + { + if (token.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + var projection = token.ToProjection(); + + _db.RefreshTokens.Add(projection); + + return Task.CompletedTask; + } + + public async Task FindByHashAsync( + string tokenHash, + CancellationToken ct = default) + { + var p = await _db.RefreshTokens + .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) + { + var query = _db.RefreshTokens + .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) + { + return _db.RefreshTokens + .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) + { + return _db.RefreshTokens + .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) + { + return _db.RefreshTokens + .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..e329766d --- /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.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +public sealed class EfCoreRefreshTokenStoreFactory : IRefreshTokenStoreFactory +{ + private readonly IServiceProvider _sp; + + public EfCoreRefreshTokenStoreFactory(IServiceProvider sp) + { + _sp = sp; + } + + public IRefreshTokenStore Create(TenantKey tenant) + { + return ActivatorUtilities.CreateInstance(_sp, tenant); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index 7046a019..683cb61b 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -5,88 +5,121 @@ namespace CodeBeam.UltimateAuth.Tokens.InMemory; -public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore +internal sealed class InMemoryRefreshTokenStore : IRefreshTokenStore { - private static string NormalizeTenant(string? tenantId) => tenantId ?? "__single__"; + private readonly TenantKey _tenant; + private readonly SemaphoreSlim _tx = new(1, 1); - private readonly ConcurrentDictionary _tokens = new(); + private readonly ConcurrentDictionary _tokens = new(); - public Task StoreAsync(TenantKey tenant, StoredRefreshToken token, CancellationToken ct = default) + public InMemoryRefreshTokenStore(TenantKey tenant) { - var key = new TokenKey(NormalizeTenant(tenant), token.TokenHash); + _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[token.TokenHash] = token; - _tokens[key] = token; return Task.CompletedTask; } - public Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default) + public Task FindByHashAsync(string tokenHash, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenant), tokenHash); + ct.ThrowIfCancellationRequested(); + + _tokens.TryGetValue(tokenHash, out var token); - _tokens.TryGetValue(key, out var token); return Task.FromResult(token); } - public Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenant), tokenHash); + ct.ThrowIfCancellationRequested(); - if (_tokens.TryGetValue(key, out var token) && !token.IsRevoked) + if (_tokens.TryGetValue(tokenHash, out var token) && !token.IsRevoked) { - _tokens[key] = token with - { - RevokedAt = revokedAt, - ReplacedByTokenHash = replacedByTokenHash - }; + _tokens[tokenHash] = token.Revoke(revokedAt, replacedByTokenHash); } return Task.CompletedTask; } - public Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.SessionId == sessionId && - !token.IsRevoked) + if (token.SessionId == sessionId && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - public Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.ChainId == chainId && - !token.IsRevoked) + if (token.ChainId == chainId && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - public Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.UserKey == userKey && - !token.IsRevoked) + if (token.UserKey == userKey && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - - private readonly record struct TokenKey(string TenantId, string TokenHash); -} +} \ No newline at end of file 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/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs index 4716253e..76b12711 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemoryTokens(this IServiceCollection services) { - services.AddSingleton(typeof(IRefreshTokenStore), typeof(InMemoryRefreshTokenStore)); + services.AddSingleton(); return services; } 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..985dcf63 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + 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..70b601f4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs @@ -0,0 +1,151 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class UAuthUserDbContext : DbContext +{ + public DbSet Identifiers => Set(); + public DbSet Lifecycles => Set(); + public DbSet Profiles => Set(); + + private readonly TenantContext _tenant; + + public UAuthUserDbContext(DbContextOptions options, TenantContext tenant) + : base(options) + { + _tenant = tenant; + } + + protected override void OnModelCreating(ModelBuilder b) + { + ConfigureTenantFilters(b); + + ConfigureIdentifiers(b); + ConfigureLifecycles(b); + ConfigureProfiles(b); + } + + private void ConfigureTenantFilters(ModelBuilder b) + { + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + } + + private void ConfigureIdentifiers(ModelBuilder b) + { + b.Entity(e => + { + 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.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 }); + + e.Property(x => x.CreatedAt) + .IsRequired(); + }); + } + + private void ConfigureLifecycles(ModelBuilder b) + { + b.Entity(e => + { + 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.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + + e.Property(x => x.SecurityVersion) + .IsRequired(); + + e.Property(x => x.CreatedAt) + .IsRequired(); + }); + } + + private void ConfigureProfiles(ModelBuilder b) + { + b.Entity(e => + { + 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.HasIndex(x => new { x.Tenant, x.UserKey }); + + e.Property(x => x.Metadata) + .HasConversion(new NullableJsonValueConverter>()) + .Metadata.SetValueComparer(JsonValueComparers.Create()); + + e.Property(x => x.CreatedAt) + .IsRequired(); + }); + } +} \ 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..5a5ed1ba --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +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 AddUltimateAuthEntityFrameworkCoreUsers(this IServiceCollection services, Action configureDb) + { + services.AddDbContextPool(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..0f7b2e88 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs @@ -0,0 +1,42 @@ +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 + }; + } +} 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..654600c3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs @@ -0,0 +1,36 @@ +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 + }; + } +} 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..d18a1bcb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs @@ -0,0 +1,52 @@ +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 + }; + } +} 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..1600e974 --- /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; + +internal 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..0f33546f --- /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; + +internal 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..6e5f07cd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs @@ -0,0 +1,41 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal 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/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs new file mode 100644 index 00000000..86fd189f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -0,0 +1,322 @@ +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 +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserIdentifierStore(UAuthUserDbContext db) + { + _db = db; + } + + public async Task ExistsAsync(Guid key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Identifiers + .AnyAsync(x => x.Id == key, ct); + } + + public async Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var q = _db.Identifiers + .AsNoTracking() + .Where(x => + x.Tenant == query.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 _db.Identifiers + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == key, ct); + + return projection?.ToDomain(); + } + + public async Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Identifiers + .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 _db.Identifiers + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == id, ct); + + return projection?.ToDomain(); + } + + public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Identifiers + .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(); + + var projection = entity.ToProjection(); + + if (entity.Version != 0) + throw new UAuthValidationException("New identifier must have version 0."); + + using var tx = await _db.Database.BeginTransactionAsync(ct); + + if (entity.IsPrimary) + { + await _db.Identifiers + .Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.IsPrimary && + x.DeletedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(i => i.IsPrimary, false), + ct); + } + + _db.Identifiers.Add(projection); + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + + public async Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + using var tx = await _db.Database.BeginTransactionAsync(ct); + + if (entity.IsPrimary) + { + await _db.Identifiers + .Where(x => + x.Tenant == entity.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); + } + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + try + { + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new UAuthConcurrencyException("identifier_concurrency_conflict"); + } + } + + public async Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); + + var normalized = query.Normalize(); + + var baseQuery = _db.Identifiers + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.UserKey == query.UserKey && + (query.IncludeDeleted || 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); + } + + public async Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Identifiers + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + userKeys.Contains(x.UserKey) && + x.DeletedAt == null) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Identifiers + .SingleOrDefaultAsync(x => x.Id == key, 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) + { + _db.Identifiers.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.IsPrimary = false; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (mode == DeleteMode.Hard) + { + await _db.Identifiers + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey) + .ExecuteDeleteAsync(ct); + + return; + } + + await _db.Identifiers + .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); + } +} 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..c2b35917 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -0,0 +1,159 @@ +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 +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserLifecycleStore(UAuthUserDbContext db) + { + _db = db; + } + + public async Task GetAsync(UserLifecycleKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Lifecycles + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + + return projection?.ToDomain(); + } + + public async Task ExistsAsync(UserLifecycleKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Lifecycles + .AnyAsync( + x => x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + } + + public async Task AddAsync(UserLifecycle entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + if (entity.Version != 0) + throw new InvalidOperationException("New lifecycle must have version 0."); + + _db.Lifecycles.Add(projection); + + await _db.SaveChangesAsync(ct); + } + + public async Task SaveAsync(UserLifecycle entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new UAuthConcurrencyException("user_lifecycle_concurrency_conflict"); + } + } + + public async Task DeleteAsync(UserLifecycleKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Lifecycles + .SingleOrDefaultAsync( + x => x.Tenant == key.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) + { + _db.Lifecycles.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = _db.Lifecycles + .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/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs new file mode 100644 index 00000000..cf7286de --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -0,0 +1,163 @@ +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 +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserProfileStore(UAuthUserDbContext db) + { + _db = db; + } + + public async Task GetAsync(UserProfileKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Profiles + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + + return projection?.ToDomain(); + } + + public async Task ExistsAsync(UserProfileKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Profiles + .AnyAsync(x => + x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + } + + public async Task AddAsync(UserProfile entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + _db.Profiles.Add(projection); + await _db.SaveChangesAsync(ct); + } + + public async Task SaveAsync(UserProfile entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + if (entity.Version != expectedVersion + 1) + throw new InvalidOperationException("Profile version must be incremented by domain."); + + _db.Entry(projection).State = EntityState.Modified; + _db.Entry(projection).Property(x => x.Version).OriginalValue = expectedVersion; + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Profiles + .SingleOrDefaultAsync(x => + x.Tenant == key.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) + { + _db.Profiles.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = _db.Profiles + .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(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Profiles + .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.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index a38424d4..f47dee0b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -144,6 +144,37 @@ public UserIdentifier MarkDeleted(DateTimeOffset 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() diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 8ee2470b..b0b64139 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -93,4 +93,29 @@ 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/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index ff0227b4..fbf58740 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -164,4 +164,45 @@ public UserProfile MarkDeleted(DateTimeOffset 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/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index f0adf68b..6b7f2055 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -20,6 +20,7 @@ + @@ -31,6 +32,7 @@ + @@ -38,6 +40,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index 3e967fce..18f513ac 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -14,9 +14,9 @@ public sealed class RefreshTokenValidatorTests { private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; - private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStore store) + private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStoreFactory factory) { - return new UAuthRefreshTokenValidator(store, CreateHasher()); + return new UAuthRefreshTokenValidator(factory, CreateHasher()); } private static ITokenHasher CreateHasher() @@ -27,8 +27,8 @@ private static ITokenHasher CreateHasher() [Fact] public async Task Invalid_When_Token_Not_Found() { - var store = new InMemoryRefreshTokenStore(); - var validator = CreateValidator(store); + var factory = new InMemoryRefreshTokenStoreFactory(); + var validator = CreateValidator(factory); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -46,26 +46,30 @@ public async Task Invalid_When_Token_Not_Found() [Fact] public async Task Reuse_Detected_When_Token_is_Revoked() { - var store = new InMemoryRefreshTokenStore(); + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + var hasher = CreateHasher(); - var validator = CreateValidator(store); + var validator = CreateValidator(factory); var now = DateTimeOffset.UtcNow; var rawToken = "refresh-token-1"; var hash = hasher.Hash(rawToken); - await store.StoreAsync(TenantKey.Single, new StoredRefreshToken - { - Tenant = TenantKey.Single, - TokenHash = hash, - UserKey = UserKey.FromString("user-1"), - SessionId = TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), - ChainId = SessionChainId.New(), - IssuedAt = now.AddMinutes(-5), - ExpiresAt = now.AddMinutes(5), - RevokedAt = now - }); + 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 @@ -83,21 +87,24 @@ public async Task Reuse_Detected_When_Token_is_Revoked() [Fact] public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() { - var store = new InMemoryRefreshTokenStore(); - var validator = CreateValidator(store); + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); var now = DateTimeOffset.UtcNow; - await store.StoreAsync(TenantKey.Single, new StoredRefreshToken - { - Tenant = TenantKey.Single, - TokenHash = "hash-2", - UserKey = UserKey.FromString("user-1"), - SessionId = TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), - ChainId = SessionChainId.New(), - IssuedAt = now, - ExpiresAt = now.AddMinutes(10) - }); + 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 @@ -113,5 +120,114 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() 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/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index e988dc1d..c5a3cfb7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -22,6 +22,7 @@ public void Revoke_marks_session_as_revoked() now, now.AddMinutes(10), 0, + DeviceContext.Create(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -46,6 +47,7 @@ public void Revoking_twice_returns_same_instance() now, now.AddMinutes(10), 0, + DeviceContext.Create(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs deleted file mode 100644 index 6ab9d80f..00000000 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -namespace CodeBeam.UltimateAuth.Tests.Unit; - -public class CredentialUserMappingBuilderTests -{ - private sealed class ConventionUser - { - public Guid Id { get; set; } - public string Email { get; set; } = default!; - public string PasswordHash { get; set; } = default!; - public long SecurityVersion { get; set; } - } - - private sealed class ExplicitUser - { - public Guid UserId { get; set; } - public string LoginName { get; set; } = default!; - public string PasswordHash { get; set; } = default!; - public long SecurityVersion { get; set; } - } - - private sealed class PlainPasswordUser - { - public Guid Id { get; set; } - public string Username { get; set; } = default!; - public string Password { get; set; } = default!; - public long SecurityVersion { get; set; } - } - - - [Fact] - public void Build_UsesConventions_WhenExplicitMappingIsNotProvided() - { - var options = new CredentialUserMappingOptions(); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ConventionUser - { - Id = Guid.NewGuid(), - Email = "test@example.com", - PasswordHash = "hash", - SecurityVersion = 3 - }; - - Assert.Equal(user.Id, mapping.UserId(user)); - Assert.Equal(user.Email, mapping.Username(user)); - Assert.Equal(user.PasswordHash, mapping.PasswordHash(user)); - Assert.Equal(user.SecurityVersion, mapping.SecurityVersion(user)); - Assert.True(mapping.CanAuthenticate(user)); - } - - [Fact] - public void Build_ExplicitMapping_OverridesConvention() - { - var options = new CredentialUserMappingOptions(); - options.MapUsername(u => u.LoginName); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ExplicitUser - { - UserId = Guid.NewGuid(), - LoginName = "custom-login", - PasswordHash = "hash", - SecurityVersion = 1 - }; - - Assert.Equal("custom-login", mapping.Username(user)); - } - - [Fact] - public void Build_DoesNotMap_PlainPassword_Property() - { - var options = new CredentialUserMappingOptions(); - var ex = Assert.Throws(() => CredentialUserMappingBuilder.Build(options)); - - Assert.Contains("PasswordHash mapping is required", ex.Message); - } - - [Fact] - public void Build_Defaults_CanAuthenticate_ToTrue() - { - var options = new CredentialUserMappingOptions(); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ConventionUser - { - Id = Guid.NewGuid(), - Email = "active@example.com", - PasswordHash = "hash", - SecurityVersion = 0 - }; - - var canAuthenticate = mapping.CanAuthenticate(user); - Assert.True(canAuthenticate); - } -} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs index fca5e3a5..1e4ad3d5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs @@ -6,6 +6,11 @@ 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}"); From 848fae172d020135054a5e69a700b4076a3a9c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:51:50 +0300 Subject: [PATCH 37/50] DX Improvization (#24) * Added Authentication EFCore Store * Completed Routes Improvization * Added InMemory & EFCore NuGet-Aimed Projects * Changed InMemory Stores With Tenant-Aware Factory Pattern * EFCore User Plugin Domain Store Tests * Added EFCore Session Store Tests * Added Authentication & Authorization EFCore Tests * Core to Server Project's NuGet Package Arrangement * Seperate Client to a new CodeBeam.UltimateAuth.Client.Blazor Package & ResourceApi Pipeline Skeleton * Completed NuGet Package Preparation * Directory.Build.props Addition * Automatized NuGet Pack Arrangements * Completed DX Fundamentals & First Quick Start and Examples in Readme.md --- .gitignore | 1 + .ultimateauth/pack.bat.txt | 27 + .ultimateauth/package.bat | 27 + Directory.Build.props | 21 + README.md | 188 +++- UltimateAuth.slnx | 15 +- ...teAuth.EntityFrameworkCoreReference.csproj | 35 + .../README.md | 32 + .../UAuthEfCoreOptions.cs | 83 ++ ...timateAuthEntityFrameworkCoreExtensions.cs | 103 ++ .../logo.png | Bin 0 -> 3551 bytes ...deBeam.UltimateAuth.InMemory.Bundle.csproj | 36 + .../CodeBeam.UltimateAuth.InMemory/README.md | 26 + .../UltimateAuthInMemoryExtensions.cs | 42 + nuget/CodeBeam.UltimateAuth.InMemory/logo.png | Bin 0 -> 3551 bytes ...eBeam.UltimateAuth.Reference.Bundle.csproj | 33 + .../README.md | 56 ++ .../UltimateAuthReferenceBundleExtensions.cs | 54 ++ .../logo.png | Bin 0 -> 3551 bytes ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 17 +- .../Components/App.razor | 2 +- .../Components/Pages/Home.razor | 2 +- .../Components/Routes.razor | 24 +- .../Components/_Imports.razor | 1 + .../Infrastructure/DarkModeManager.cs | 4 +- .../Program.cs | 31 +- ...am.UltimateAuth.Sample.BlazorServer.csproj | 21 +- .../Components/App.razor | 2 +- .../Dialogs/AccountStatusDialog.razor.cs | 4 +- .../Dialogs/CreateUserDialog.razor.cs | 2 +- .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Dialogs/IdentifierDialog.razor.cs | 12 +- .../Dialogs/PermissionDialog.razor.cs | 2 +- .../Components/Dialogs/ProfileDialog.razor.cs | 2 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Components/Dialogs/RoleDialog.razor.cs | 8 +- .../Components/Dialogs/SessionDialog.razor.cs | 14 +- .../Dialogs/UserDetailDialog.razor.cs | 2 +- .../Dialogs/UserRoleDialog.razor.cs | 4 +- .../Components/Dialogs/UsersDialog.razor.cs | 4 +- .../Components/Pages/Home.razor.cs | 2 +- .../Components/Pages/Login.razor.cs | 3 +- .../Components/Pages/Register.razor.cs | 4 +- .../Components/Routes.razor | 24 +- .../Components/_Imports.razor | 1 + .../Infrastructure/DarkModeManager.cs | 4 +- .../Program.cs | 48 +- .../App.razor | 28 +- ...ateAuth.Sample.BlazorStandaloneWasm.csproj | 17 +- .../Dialogs/AccountStatusDialog.razor.cs | 4 +- .../Dialogs/CreateUserDialog.razor.cs | 2 +- .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Dialogs/IdentifierDialog.razor.cs | 12 +- .../Dialogs/PermissionDialog.razor.cs | 2 +- .../Components/Dialogs/ProfileDialog.razor.cs | 2 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Components/Dialogs/RoleDialog.razor.cs | 8 +- .../Components/Dialogs/SessionDialog.razor.cs | 14 +- .../Dialogs/UserDetailDialog.razor.cs | 2 +- .../Dialogs/UserRoleDialog.razor.cs | 4 +- .../Components/Dialogs/UsersDialog.razor.cs | 4 +- .../Infrastructure/DarkModeManager.cs | 4 +- .../Pages/Home.razor.cs | 2 +- .../Pages/Login.razor.cs | 1 + .../Pages/Register.razor.cs | 4 +- .../Program.cs | 4 +- .../_Imports.razor | 1 + .../wwwroot/index.html | 2 +- ...eam.UltimateAuth.Sample.ResourceApi.csproj | 5 +- .../Program.cs | 13 +- .../AuthState/UAuthCascadingStateProvider.cs | 24 - .../Components/UAuthApp.razor | 22 - .../Runtime/UAuthClientMarker.cs | 5 - .../IAuthenticationSecurityManager.cs | 0 .../IAuthenticationSecurityStateStore.cs | 5 +- ...AuthenticationSecurityStateStoreFactory.cs | 9 + .../Abstractions/Entity/ITenantEntity.cs | 8 + .../Abstractions/Stores/IRefreshTokenStore.cs | 1 - .../Abstractions/Stores/ISessionStore.cs | 6 +- .../AssemblyVisibility.cs | 1 + .../CodeBeam.UltimateAuth.Core.csproj | 23 +- .../Contracts/Common/UAuthResult.cs | 2 +- .../Domain/Device/DeviceContext.cs | 6 +- .../Domain/Device/DeviceId.cs | 5 +- .../Security/AuthenticationSecurityState.cs | 68 +- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Converters/DeviceContextJsonConverter.cs | 65 ++ .../Converters/DeviceIdJsonConverter.cs | 23 + src/CodeBeam.UltimateAuth.Core/README.md | 14 + src/CodeBeam.UltimateAuth.Core/logo.png | Bin 0 -> 3551 bytes .../Auth/Context/AccessContextFactory.cs | 9 +- .../AuthenticationSecurityManager.cs | 23 +- .../CodeBeam.UltimateAuth.Server.csproj | 29 +- .../Endpoints/UAuthEndpointRegistrar.cs | 1 - .../Extensions/ServiceCollectionExtensions.cs | 72 ++ .../Extensions/UAuthRazorExtensions.cs | 2 +- .../Flows/Login/LoginOrchestrator.cs | 4 +- .../Infrastructure/User/UserAccessorBridge.cs | 1 - .../Validator/UserCreateValidator.cs | 1 + src/CodeBeam.UltimateAuth.Server/README.md | 25 + .../AllowAllAccessPolicyProvider.cs | 33 + .../ResourceApi/NoOpIdentifierValidator.cs | 13 + .../ResourceApi/NoOpRefreshTokenValidator.cs | 15 + .../ResourceApi/NoOpSessionValidator.cs | 14 + .../ResourceApi/NoOpTokenHasher.cs | 9 + .../ResourceApi/NoOpUserClaimsProvider.cs | 17 + .../ResourceApi/NotSupportedPasswordHasher.cs | 16 + .../NotSupportedRefreshTokenStoreFactory.cs | 12 + .../NotSupportedSessionStoreFactory.cs | 12 + .../NotSupportedUserRoleStoreFactory.cs | 12 + .../Runtime/ResourceRuntimeMarker.cs | 7 + .../Stores/AspNetIdentityUserStore.cs | 32 - src/CodeBeam.UltimateAuth.Server/logo.png | Bin 0 -> 3551 bytes .../AssemblyVisibility.cs | 0 ....Authentication.EntityFrameworkCore.csproj | 29 + .../Data/UAuthAuthenticationDbContext.cs | 78 ++ .../Extensions/ServiceCollectionExtensions.cs | 15 + .../AuthenticationSecurityStateMapper.cs | 65 ++ .../AuthenticationSecutiryStateProjection.cs | 28 + .../README.md | 32 + .../EfCoreAuthenticationSecurityStateStore.cs | 82 ++ ...AuthenticationSecurityStateStoreFactory.cs | 19 + .../logo.png | Bin 0 -> 3551 bytes ...ltimateAuth.Authentication.InMemory.csproj | 21 +- ...nMemoryAuthenticationSecurityStateStore.cs | 48 +- ...AuthenticationSecurityStateStoreFactory.cs | 15 + .../README.md | 31 + .../ServiceCollectionExtensions.cs | 7 +- .../logo.png | Bin 0 -> 3551 bytes ...ltimateAuth.Authorization.Contracts.csproj | 26 +- .../README.md | 32 + .../logo.png | Bin 0 -> 3551 bytes ...h.Authorization.EntityFrameworkCore.csproj | 24 +- .../Data/UAuthAuthorizationDbContext.cs | 18 +- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Mappers/RolePermissionMapper.cs | 1 - .../README.md | 31 + .../Stores/EfCoreRoleStore.cs | 75 +- .../Stores/EfCoreRoleStoreFactory.cs | 18 + .../Stores/EfCoreUserRoleStore.cs | 36 +- .../Stores/EfCoreUserRoleStoreFactory.cs | 18 + .../logo.png | Bin 0 -> 3551 bytes ...UltimateAuth.Authorization.InMemory.csproj | 24 +- .../Extensions/ServiceCollectionExtensions.cs | 7 +- .../InMemoryAuthorizationSeedContributor.cs | 71 +- .../README.md | 31 + .../Stores/InMemoryRoleStore.cs | 44 +- .../Stores/InMemoryRoleStoreFactory.cs | 14 + .../Stores/InMemoryUserRoleStore.cs | 44 +- .../Stores/InMemoryUserRoleStoreFactory.cs | 14 + .../logo.png | Bin 0 -> 3551 bytes ...ltimateAuth.Authorization.Reference.csproj | 24 +- .../Infrastructure/RolePermissionResolver.cs | 10 +- .../Infrastructure/UserPermissionStore.cs | 9 +- .../README.md | 44 + .../Services/RoleService.cs | 40 +- .../Services/UserRoleService.cs | 28 +- .../logo.png | Bin 0 -> 3551 bytes .../Abstractions/IRolePermissionResolver.cs | 1 - .../Abstractions/IRoleService.cs | 1 - .../Abstractions/IRoleStore.cs | 8 +- .../Abstractions/IRoleStoreFactory.cs | 8 + .../Abstractions/IUserPermissionStore.cs | 1 - .../Abstractions/IUserRoleStore.cs | 11 +- .../Abstractions/IUserRoleStoreFactory.cs | 8 + ...CodeBeam.UltimateAuth.Authorization.csproj | 29 +- .../Domain/Role.cs | 3 +- .../Domain/RoleKey.cs | 2 +- .../AuthorizationClaimsProvider.cs | 16 +- .../README.md | 23 + .../logo.png | Bin 0 -> 3551 bytes .../AssemblyVisibility.cs | 3 + .../UAuthAuthenticationStateProvider.cs | 2 +- ...odeBeam.UltimateAuth.Client.Blazor.csproj} | 51 +- .../Components/UALoginDispatch.razor | 2 +- .../Components/UAuthApp.razor | 51 + .../Components/UAuthApp.razor.cs | 37 +- .../Components/UAuthFlowPageBase.cs | 2 +- .../Components/UAuthLoginForm.razor | 2 +- .../Components/UAuthLoginForm.razor.cs | 2 +- .../Components/UAuthReactiveComponentBase.cs | 2 +- .../Components/UAuthScope.razor | 2 +- .../Components/UAuthScope.razor.cs | 3 +- .../Components/UAuthStateView.razor | 2 +- .../Components/UAuthStateView.razor.cs | 2 +- .../Device/BrowserDeviceIdStorage.cs | 7 +- .../Extensions/AssemblyExtensions.cs | 21 + .../Extensions/ServiceCollectionExtensions.cs | 58 ++ .../Infrastructure/BlazorReturnUrlProvider.cs | 16 + .../Infrastructure/BrowserClientStorage.cs} | 7 +- .../Infrastructure/BrowserUAuthBridge.cs | 5 +- .../Infrastructure/SessionCoordinator.cs | 2 +- .../UAuthLoginPageDiscovery.cs | 0 .../Infrastructure/UAuthRequestClient.cs | 3 +- .../README.md | 12 + .../Runtime/UAuthBlazorClientMarker.cs | 5 + .../TScripts/uauth.js | 0 .../_Imports.razor | 1 + .../logo.png | Bin 0 -> 3551 bytes .../wwwroot/uauth.min.js | 0 ...Beam.UltimateAuth.Client.JsMinifier.csproj | 3 +- .../Program.cs | 0 .../Abstractions/IClientStorage.cs} | 2 +- .../Abstractions/IReturnUrlProvider.cs | 6 + .../Abstractions/ISessionCoordinator.cs | 0 .../AssemblyVisibility.cs | 4 + .../AuthState/IUAuthStateManager.cs | 0 .../AuthState/UAuthState.cs | 0 .../AuthState/UAuthStateChangeReason.cs | 0 .../AuthState/UAuthStateEvent.cs | 0 .../AuthState/UAuthStateEventArgs.cs | 0 .../AuthState/UAuthStateEventHandlingMode.cs | 0 .../AuthState/UAuthStateManager.cs | 0 .../CodeBeam.UltimateAuth.Client.csproj | 28 + .../Contracts/CoordinatorTerminationReason.cs | 0 .../Contracts/PkceClientState.cs | 0 .../Contracts/RefreshResult.cs | 0 .../Contracts/StorageScope.cs | 0 .../Contracts/TenantTransport.cs | 0 .../Contracts/UAuthRenderMode.cs | 2 +- .../Contracts/UAuthTransportResult.cs | 0 .../Device/IDeviceIdGenerator.cs | 0 .../Device/IDeviceIdProvider.cs | 0 .../Device/IDeviceIdStorage.cs | 0 .../Device/UAuthDeviceIdGenerator.cs | 2 +- .../Device/UAuthDeviceIdProvider.cs | 2 +- .../Diagnostics/UAuthClientDiagnostics.cs | 0 .../Errors/UAuthClientException.cs | 0 .../Errors/UAuthProtocolException.cs | 0 .../Errors/UAuthTransportException.cs | 0 .../Events/IUAuthClientEvents.cs | 0 .../Events/UAuthClientEvents.cs | 0 .../Extensions/LoginRequestFormExtensions.cs | 0 .../Extensions/ServiceCollectionExtensions.cs | 54 +- .../Infrastructure/ClientClock.cs | 0 .../ClientLoginCapabilities.cs | 0 .../Infrastructure/IBrowserUAuthBridge.cs | 0 .../IUAuthClientBootstrapper.cs | 0 .../Infrastructure/IUAuthRequestClient.cs | 0 .../Infrastructure/NoOpHubCapabilities.cs | 0 .../NoOpHubCredentialResolver.cs | 0 .../Infrastructure/NoOpHubFlowReader.cs | 0 .../Infrastructure/NoOpSessionCoordinator.cs | 0 .../Infrastructure/RefreshOutcomeParser.cs | 0 .../Infrastructure/UAuthClientBootstrapper.cs | 0 .../UAuthLoginPageAttribute.cs | 0 .../Infrastructure/UAuthResultMapper.cs | 0 .../Infrastructure/UAuthUrlBuilder.cs | 2 +- .../Options/ClientConfigurationMarker.cs | 15 + .../Options/UAuthClientAutoRefreshOptions.cs | 0 .../Options/UAuthClientEndpointOptions.cs | 0 .../Options/UAuthClientLoginFlowOptions.cs | 0 .../Options/UAuthClientMultiTenantOptions.cs | 0 .../Options/UAuthClientOptions.cs | 0 .../UAuthClientPkceLoginFlowOptions.cs | 0 .../Options/UAuthClientProfileDetector.cs | 0 .../Options/UAuthClientReauthOptions.cs | 0 .../Options/UAuthOptionsPostConfigure.cs | 0 .../Options/UAuthStateEventOptions.cs | 0 .../UAuthClientEndpointOptionsValidator.cs | 0 .../Validators/UAuthClientOptionsValidator.cs | 0 .../CodeBeam.UltimateAuth.Client/README.md | 31 + .../IUAuthClientProductInfoProvider.cs | 0 .../Runtime/UAuthClientProductInfo.cs | 0 .../Runtime/UAuthClientProductInfoProvider.cs | 0 .../Abstractions/IAuthorizationClient.cs | 3 +- .../Abstractions/ICredentialClient.cs | 0 .../Services/Abstractions/IFlowClient.cs | 0 .../Services/Abstractions/ISessionClient.cs | 0 .../Services/Abstractions/IUAuthClient.cs | 0 .../Services/Abstractions/IUserClient.cs | 0 .../Abstractions/IUserIdentifierClient.cs | 0 .../Services/UAuthAuthorizationClient.cs | 0 .../Services/UAuthClient.cs | 0 .../Services/UAuthCredentialClient.cs | 0 .../Services/UAuthFlowClient.cs | 12 +- .../Services/UAuthSessionClient.cs | 0 .../Services/UAuthUserClient.cs | 0 .../Services/UAuthUserIdentifierClient.cs | 0 .../CodeBeam.UltimateAuth.Client/logo.png | Bin 0 -> 3551 bytes ....UltimateAuth.Credentials.Contracts.csproj | 22 +- .../README.md | 32 + .../logo.png | Bin 0 -> 3551 bytes ...uth.Credentials.EntityFrameworkCore.csproj | 26 +- .../Data/UAuthCredentialDbContext.cs | 10 +- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../PasswordCredentialProjectionMapper.cs | 9 +- .../README.md | 31 + .../Stores/EfCorePasswordCredentialStore.cs | 80 +- .../EfCorePasswordCredentialStoreFactory.cs | 19 + .../logo.png | Bin 0 -> 3551 bytes ...m.UltimateAuth.Credentials.InMemory.csproj | 30 +- .../InMemoryCredentialSeedContributor.cs | 11 +- .../InMemoryPasswordCredentialStore.cs | 25 +- .../InMemoryPasswordCredentialStoreFactory.cs | 15 + .../README.md | 31 + .../ServiceCollectionExtensions.cs | 3 +- .../logo.png | Bin 0 -> 3551 bytes ....UltimateAuth.Credentials.Reference.csproj | 22 +- .../Domain/PasswordCredential.cs | 27 +- .../Extensions/ServiceCollectionExtensions.cs | 21 +- .../PasswordCredentialProvider.cs | 9 +- .../PasswordUserLifecycleIntegration.cs | 12 +- .../README.md | 40 + .../Services/CredentialManagementService.cs | 43 +- .../{ => Stores}/IPasswordCredentialStore.cs | 5 +- .../Stores/IPasswordCredentialStoreFactory.cs | 8 + .../logo.png | Bin 0 -> 3551 bytes .../Abstractions/ICredentialStore.cs | 18 - .../CodeBeam.UltimateAuth.Credentials.csproj | 29 +- .../README.md | 22 + .../logo.png | Bin 0 -> 3551 bytes ...m.UltimateAuth.EntityFrameworkCore.csproj} | 32 +- .../AuthSessionIdEfConverter.cs | 0 .../Infrastructure/JsonSerializeWrapper.cs | 0 .../Infrastructure/JsonValueComparers.cs | 0 .../Infrastructure/JsonValueConverter.cs | 0 .../NullableAuthSessionIdConverter.cs | 0 .../NullableJsonValueConverter.cs | 0 .../NullableSessionChainIdConverter.cs | 0 .../Infrastructure/SessionChainIdConverter.cs | 0 .../SessionChainIdEfConverter.cs | 0 .../README.md | 32 + .../logo.png | Bin 0 -> 3551 bytes .../CodeBeam.UltimateAuth.InMemory.csproj | 29 + .../IInMemoryUserIdProvider.cs | 2 +- .../InMemoryTenantVersionedStore.cs | 51 + .../InMemoryVersionedStore.cs | 5 +- .../CodeBeam.UltimateAuth.InMemory/README.md | 34 + .../CodeBeam.UltimateAuth.InMemory/logo.png | Bin 0 -> 3551 bytes .../CodeBeam.UltimateAuth.Policies.csproj | 29 +- .../CodeBeam.UltimateAuth.Policies/README.md | 23 + .../CodeBeam.UltimateAuth.Policies/logo.png | Bin 0 -> 3551 bytes .../Argon2PasswordHasher.cs | 24 +- .../AssemblyVisibility.cs | 4 + ...deBeam.UltimateAuth.Security.Argon2.csproj | 21 +- .../README.md | 28 + .../ServiceCollectionExtensions.cs | 11 +- .../logo.png | Bin 0 -> 3551 bytes .../AssemblyVisibility.cs | 3 + ...teAuth.Sessions.EntityFrameworkCore.csproj | 21 +- .../Data/UAuthSessionDbContext.cs | 42 +- .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../Mappers/SessionChainProjectionMapper.cs | 14 + .../Mappers/SessionProjectionMapper.cs | 11 + .../Mappers/SessionRootProjectionMapper.cs | 8 + .../README.md | 32 + .../Stores/EfCoreSessionStore.cs | 324 ++++--- .../Stores/EfCoreSessionStoreFactory.cs | 17 +- .../logo.png | Bin 0 -> 3551 bytes ...Beam.UltimateAuth.Sessions.InMemory.csproj | 21 +- .../InMemorySessionStore.cs | 40 +- .../InMemorySessionStoreFactory.cs | 2 +- .../README.md | 31 + .../ServiceCollectionExtensions.cs | 4 +- .../logo.png | Bin 0 -> 3551 bytes ...mateAuth.Tokens.EntityFrameworkCore.csproj | 22 +- .../Data/UAuthTokenDbContext.cs | 13 +- .../Extensions/ServiceCollectionExtensions.cs | 7 +- .../README.md | 33 + .../Stores/EfCoreRefreshTokenStore.cs | 77 +- .../Stores/EfCoreRefreshTokenStoreFactory.cs | 11 +- .../logo.png | Bin 0 -> 3551 bytes ...deBeam.UltimateAuth.Tokens.InMemory.csproj | 21 +- .../InMemoryRefreshTokenStore.cs | 33 +- .../README.md | 31 + .../ServiceCollectionExtensions.cs | 5 +- .../logo.png | Bin 0 -> 3551 bytes ...deBeam.UltimateAuth.Users.Contracts.csproj | 20 +- .../Dtos/IdentifierExistenceQuery.cs | 1 - .../README.md | 27 + .../logo.png | Bin 0 -> 3551 bytes ...imateAuth.Users.EntityFrameworkCore.csproj | 23 +- .../Data/UAuthUserDbContext.cs | 24 +- .../Extensions/ServiceCollectionExtensions.cs | 10 +- .../Mappers/UserIdentifierMapper.cs | 13 + .../Mappers/UserLifecycleMapper.cs | 8 + .../Mappers/UserProfileMapper.cs | 20 + .../README.md | 28 + .../Stores/EFCoreUserProfileStoreFactory.cs | 19 + .../Stores/EfCoreUserIdentifierStore.cs | 214 +++-- .../EfCoreUserIdentifierStoreFactory.cs | 20 + .../Stores/EfCoreUserLifecycleStore.cs | 56 +- .../Stores/EfCoreUserLifecycleStoreFactory.cs | 20 + .../Stores/EfCoreUserProfileStore.cs | 41 +- .../logo.png | Bin 0 -> 3551 bytes ...odeBeam.UltimateAuth.Users.InMemory.csproj | 28 +- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Infrastructure/InMemoryUserIdProvider.cs | 2 +- .../InMemoryUserSeedContributor.cs | 117 +-- .../README.md | 35 + .../Stores/InMemoryUserIdentifierStore.cs | 42 +- .../InMemoryUserIdentifierStoreFactory.cs | 27 + .../Stores/InMemoryUserLifecycleStore.cs | 23 +- .../InMemoryUserLifecycleStoreFactory.cs | 26 + .../Stores/InMemoryUserProfileStore.cs | 22 +- .../Stores/InMemoryUserProfileStoreFactory.cs | 26 + .../logo.png | Bin 0 -> 3551 bytes ...deBeam.UltimateAuth.Users.Reference.csproj | 22 +- .../Domain/UserIdentifier.cs | 3 +- .../Domain/UserLifecycle.cs | 2 +- .../Domain/UserProfile.cs | 6 +- .../Infrastructure/LoginIdentifierResolver.cs | 9 +- .../PrimaryUserIdentifierProvider.cs | 9 +- .../UserLifecycleSnaphotProvider.cs | 9 +- .../UserProfileSnapshotProvider.cs | 9 +- .../README.md | 40 + .../Services/UserApplicationService.cs | 156 ++-- .../Stores/IUserIdentifierStore.cs | 11 +- .../Stores/IUserIdentifierStoreFactory.cs | 8 + .../Stores/IUserLifecycleStore.cs | 5 +- .../Stores/IUserLifecycleStoreFactory.cs | 8 + .../Stores/IUserProfileStore.cs | 5 +- .../Stores/IUserProfileStoreFactory.cs | 8 + .../Stores/UserRuntimeStateProvider.cs | 9 +- .../logo.png | Bin 0 -> 3551 bytes .../ICustomLoginIdentifierResolver.cs | 0 .../ILoginIdentifierResolver.cs | 0 .../Abstractions}/IUserCreateValidator.cs | 2 +- .../CodeBeam.UltimateAuth.Users.csproj | 27 +- .../CodeBeam.UltimateAuth.Users/README.md | 22 + .../CodeBeam.UltimateAuth.Users/logo.png | Bin 0 -> 3551 bytes .../AssemblyVisibility.cs | 3 + .../Client/SessionCoordinatorTests.cs | 2 +- .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 10 +- .../EfCoreAuthenticationStoreTests.cs | 214 +++++ .../EfCoreCredentialStoreTests.cs | 294 ++++++ .../EfCoreRoleStoreTests.cs | 253 +++++ .../EfCoreSessionStoreTests.cs | 876 ++++++++++++++++++ .../EfCoreTokenStoreTests.cs | 122 +++ .../EfCoreUserIdentifierStoreTests.cs | 331 +++++++ .../EfCoreUserLifecycleStoreTests.cs | 227 +++++ .../EfCoreUserProfileStoreTests.cs | 215 +++++ .../EfCoreUserRoleStoreTests.cs | 152 +++ .../Helpers/EfCoreTestBase.cs | 26 + .../Helpers/TestAuthRuntime.cs | 38 +- .../Security/Argon2PasswordHasherTest.cs | 86 ++ .../Server/LoginOrchestratorTests.cs | 34 +- .../Sessions/SessionTests.cs | 4 +- .../Users/IdentifierConcurrencyTests.cs | 18 +- 440 files changed, 7885 insertions(+), 1603 deletions(-) create mode 100644 .ultimateauth/pack.bat.txt create mode 100644 .ultimateauth/package.bat create mode 100644 Directory.Build.props create mode 100644 nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCoreReference.csproj create mode 100644 nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/README.md create mode 100644 nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UAuthEfCoreOptions.cs create mode 100644 nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UltimateAuthEntityFrameworkCoreExtensions.cs create mode 100644 nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/logo.png create mode 100644 nuget/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.Bundle.csproj create mode 100644 nuget/CodeBeam.UltimateAuth.InMemory/README.md create mode 100644 nuget/CodeBeam.UltimateAuth.InMemory/UltimateAuthInMemoryExtensions.cs create mode 100644 nuget/CodeBeam.UltimateAuth.InMemory/logo.png create mode 100644 nuget/CodeBeam.UltimateAuth.Reference.Bundle/CodeBeam.UltimateAuth.Reference.Bundle.csproj create mode 100644 nuget/CodeBeam.UltimateAuth.Reference.Bundle/README.md create mode 100644 nuget/CodeBeam.UltimateAuth.Reference.Bundle/UltimateAuthReferenceBundleExtensions.cs create mode 100644 nuget/CodeBeam.UltimateAuth.Reference.Bundle/logo.png delete mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor delete mode 100644 src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientMarker.cs rename src/CodeBeam.UltimateAuth.Core/Abstractions/{Security => Authentication}/IAuthenticationSecurityManager.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Abstractions/{Security => Authentication}/IAuthenticationSecurityStateStore.cs (50%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/ITenantEntity.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceContextJsonConverter.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/README.md create mode 100644 src/CodeBeam.UltimateAuth.Core/logo.png create mode 100644 src/CodeBeam.UltimateAuth.Server/README.md create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Runtime/ResourceRuntimeMarker.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/logo.png rename src/{CodeBeam.UltimateAuth.Client => authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore}/AssemblyVisibility.cs (100%) create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.csproj create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Mappers/AuthenticationSecurityStateMapper.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Projections/AuthenticationSecutiryStateProjection.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/README.md create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/logo.png create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStoreFactory.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/README.md create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/logo.png create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/README.md create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/logo.png create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/README.md create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/logo.png create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/README.md create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStoreFactory.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/logo.png create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/README.md create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/logo.png create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStoreFactory.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStoreFactory.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/README.md create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/logo.png create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/AssemblyVisibility.cs rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/AuthState/UAuthAuthenticationStateProvider.cs (93%) rename src/{CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj => client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj} (64%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UALoginDispatch.razor (97%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthApp.razor.cs (70%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthFlowPageBase.cs (98%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthLoginForm.razor (96%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthLoginForm.razor.cs (99%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthReactiveComponentBase.cs (97%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthScope.razor (50%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthScope.razor.cs (66%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthStateView.razor (92%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Components/UAuthStateView.razor.cs (98%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Device/BrowserDeviceIdStorage.cs (79%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BlazorReturnUrlProvider.cs rename src/{CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs => client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs} (80%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Infrastructure/BrowserUAuthBridge.cs (68%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Infrastructure/SessionCoordinator.cs (97%) rename src/{CodeBeam.UltimateAuth.Client/Infrastructure/Login => client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure}/UAuthLoginPageDiscovery.cs (100%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/Infrastructure/UAuthRequestClient.cs (96%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/README.md create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/TScripts/uauth.js (100%) rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/_Imports.razor (79%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/logo.png rename src/{CodeBeam.UltimateAuth.Client => client/CodeBeam.UltimateAuth.Client.Blazor}/wwwroot/uauth.min.js (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj (77%) rename src/{ => client}/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs (100%) rename src/{CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs => client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs} (91%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs rename src/{ => client}/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs (100%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs rename src/{ => client}/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs (100%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj rename src/{ => client}/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs (98%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs (85%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs (94%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs (73%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs (100%) rename src/{CodeBeam.UltimateAuth.Client/Infrastructure/Login => client/CodeBeam.UltimateAuth.Client/Infrastructure}/ClientLoginCapabilities.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs (100%) rename src/{CodeBeam.UltimateAuth.Client/Infrastructure/Login => client/CodeBeam.UltimateAuth.Client/Infrastructure}/UAuthLoginPageAttribute.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs (95%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client/Options/ClientConfigurationMarker.cs rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs (100%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client/README.md rename src/{ => client}/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs (91%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs (96%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs (100%) rename src/{ => client}/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs (100%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client/logo.png create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/README.md create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/logo.png create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/README.md create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/logo.png create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/README.md create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/logo.png create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/README.md rename src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/{ => Stores}/IPasswordCredentialStore.cs (68%) create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStoreFactory.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/logo.png delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/README.md create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/logo.png rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj => CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj} (53%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/AuthSessionIdEfConverter.cs (100%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/JsonSerializeWrapper.cs (100%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/JsonValueComparers.cs (100%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/JsonValueConverter.cs (100%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/NullableAuthSessionIdConverter.cs (100%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/NullableJsonValueConverter.cs (100%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/NullableSessionChainIdConverter.cs (100%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/SessionChainIdConverter.cs (100%) rename src/persistence/{CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions => CodeBeam.UltimateAuth.EntityFrameworkCore}/Infrastructure/SessionChainIdEfConverter.cs (100%) create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/README.md create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/logo.png create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.csproj rename src/{CodeBeam.UltimateAuth.Core/Infrastructure => persistence/CodeBeam.UltimateAuth.InMemory}/IInMemoryUserIdProvider.cs (67%) create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs rename src/{CodeBeam.UltimateAuth.Core/Abstractions/Stores => persistence/CodeBeam.UltimateAuth.InMemory}/InMemoryVersionedStore.cs (96%) create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/README.md create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/logo.png create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/README.md create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/logo.png create mode 100644 src/security/CodeBeam.UltimateAuth.Security.Argon2/AssemblyVisibility.cs create mode 100644 src/security/CodeBeam.UltimateAuth.Security.Argon2/README.md create mode 100644 src/security/CodeBeam.UltimateAuth.Security.Argon2/logo.png create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AssemblyVisibility.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/README.md create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/logo.png create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/README.md create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/logo.png create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/README.md create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/logo.png create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/README.md create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/logo.png create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/README.md create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/logo.png create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/README.md create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/logo.png create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/README.md create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/logo.png create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/README.md create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/logo.png rename src/users/CodeBeam.UltimateAuth.Users/{Infrastructure => Abstractions}/ICustomLoginIdentifierResolver.cs (100%) rename src/users/CodeBeam.UltimateAuth.Users/{Infrastructure => Abstractions}/ILoginIdentifierResolver.cs (100%) rename src/{CodeBeam.UltimateAuth.Server/Infrastructure/Validator => users/CodeBeam.UltimateAuth.Users/Abstractions}/IUserCreateValidator.cs (83%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users/README.md create mode 100644 src/users/CodeBeam.UltimateAuth.Users/logo.png create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/EfCoreTestBase.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs 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/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 91dbb94e..69aad5de 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ UltimateAuth is an open-source authentication framework that unifies secure sess ## 🌟 Why UltimateAuth: The Six-Point Principles -### **1) Developer-Centric** +### **1) Developer-Centric & User-Friendly** Clean APIs, predictable behavior, minimal ceremony — designed to make authentication *pleasant* for developers. ### **2) Security-Driven** @@ -38,40 +38,159 @@ No forced dependencies. No unnecessary weight. ### **4) Plug-and-Play Ready** From setup to production, UltimateAuth prioritizes a frictionless integration journey with sensible defaults. -### **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** +### **5) 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. ---- - -## 🔑 What UltimateAuth Provides +### **6) Unified Framework** +One solution, same codebase across Blazor server, WASM and MAUI. UltimateAuth handles client differences internally and providing consistent and reliable public API. -- A **secure, modern session-based authentication core** - (opaque SessionId, server-managed, real-time revocation, device tracking) - -- A **unified architecture** bridging Session, PKCE, and OAuth-style auth flows +--- -- An **override-first design** suitable for enterprise extensions +# 🚀 Quick Start + +### 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(); // Production + +// 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` +```csharp + +``` + +### 5) Optional: Blazor Usings +Add this in `_Imports.razor` +```csharp +@using CodeBeam.UltimateAuth.Client.Blazor +``` + +### ✅ Done -- A **production-grade client SDK** for Blazor & MAUI +--- -- A **fully interactive sandbox** - where developers can test flows, create accounts, simulate devices, and validate behaviors in real time +## 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.LogoutOtherDevicesSelfAsync(); + 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. --- + ## 📅 Release Timeline (Targeted) > _Dates reflect targeted milestones and may evolve with community feedback._ -### **Q1 2026 — First Release (v 0.0.1 to v 0.1.0)** -- Core session-based auth engine +### **Q1 2026 — First Release** +- v 0.1.0-preview to v 0.1.0 -### **Q2 2026 — Stable Feature Release** +### **Q2 2026 — Stable Feature Releases** +- v 0.2.0 to v 0.3.0 ### **Q3 2026 — General Availability** - API surface locked @@ -83,6 +202,18 @@ UltimateAuth adopts .NET platform versioning to align with the broader ecosystem --- +## 🗺 Roadmap + +The project roadmap is actively maintained as a GitHub issue: + +👉 https://github.com/CodeBeamOrg/UltimateAuth/issues/8 + +We keep it up-to-date with current priorities, planned features, and progress. + +Feel free to follow, comment, or contribute ideas. + +--- + ## 📘 Documentation Two documentation experiences will be provided: @@ -100,20 +231,23 @@ 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. +UltimateAuth core architecture is implemented and validated through the sample application. + +We are currently: + +- Polishing developer experience +- Reviewing public APIs +- Preparing EF Core integration packages + +Preview release is coming soon. -Early API drafts and prototypes will be published soon. +You can check the samples and try what UltimateAuth offers by downloading repo and running locally. --- diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 7423241a..901d2484 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,4 +1,9 @@ + + + + + @@ -6,20 +11,22 @@ + + - - + + @@ -27,7 +34,8 @@ - + + @@ -39,4 +47,5 @@ + diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCoreReference.csproj b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCoreReference.csproj new file mode 100644 index 00000000..6b0ed53a --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCoreReference.csproj @@ -0,0 +1,35 @@ + + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle + + + Provides a complete Entity Framework Core persistence setup for UltimateAuth. + This package includes reference domain implementations and Entity Framework Core-based persistence for all modules. + It is designed for production scenarios requiring durable storage. + + + authentication;authorization;identity;efcore;inmemory;bundle;auth-framework;security;jwt + logo.png + README.md + + + + + + + + + + + + + + + + + + diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/README.md b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/README.md new file mode 100644 index 00000000..4d0756a1 --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/README.md @@ -0,0 +1,32 @@ +# UltimateAuth EntityFrameworkCore Bundle + +Provides a complete production-ready setup for UltimateAuth using Entity Framework Core. + +## 🚀 Quick Start + +```csharp +builder.Services + .AddUltimateAuthServer() + .AddUltimateAuthEntityFrameworkCore(options => + { + options.UseSqlServer("connection-string"); + }); +``` + +## 📦 Includes + +- Reference domain implementations + +EF Core persistence for: + +- Users +- Credentials +- Authorization +- Sessions +- Tokens +- Authentication + +## ⚠️ Notes + +- You must configure a database provider +- Migrations are not applied automatically \ No newline at end of file diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UAuthEfCoreOptions.cs b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UAuthEfCoreOptions.cs new file mode 100644 index 00000000..464cf4a5 --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UAuthEfCoreOptions.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore +{ + /// + /// Provides configuration options for setting up Entity Framework Core database contexts used by the UltimateAuth + /// system. + /// + /// Use this class to specify delegates that configure the options for various DbContext + /// instances, such as Users, Credentials, Authorization, Sessions, Tokens, and Authentication. Each property allows + /// customization of the corresponding context's setup, including database provider and connection details. If a + /// specific configuration delegate is not set for a context, the default configuration is applied. This class is + /// typically configured during application startup to ensure consistent and flexible database context + /// initialization. + public sealed class UAuthEfCoreOptions + { + /// + /// Gets or sets the default action to configure the database context options builder. + /// + /// Use this property to specify a delegate that applies default configuration to a + /// DbContextOptionsBuilder instance. This action is typically invoked when setting up a new database context to + /// ensure consistent configuration across the application. + public Action? Default { get; set; } + + + /// + /// Gets or sets the delegate used to configure options for the Users DbContext. + /// If not set, default option will implement. + /// + /// Assign a delegate to customize the configuration of the Users DbContext, such as + /// specifying the database provider or connection string. This property is typically used during application + /// startup to control how the Users context is set up. + public Action? Users { get; set; } + + /// + /// Gets or sets the delegate used to configure options for the Credentials DbContext. + /// If not set, default option will implement. + /// + /// Assign a delegate to customize the configuration of the Credentials DbContext, such as + /// specifying the database provider or connection string. This property is typically used during application + /// startup to control how the Credentials context is set up. + public Action? Credentials { get; set; } + + /// + /// Gets or sets the delegate used to configure options for the Authorization DbContext. + /// If not set, default option will implement. + /// + /// Assign a delegate to customize the configuration of the Authorization DbContext, such as + /// specifying the database provider or connection string. This property is typically used during application + /// startup to control how the Authorization context is set up. + public Action? Authorization { get; set; } + + /// + /// Gets or sets the delegate used to configure options for the Sessions DbContext. + /// If not set, default option will implement. + /// + /// Assign a delegate to customize the configuration of the Sessions DbContext, such as + /// specifying the database provider or connection string. This property is typically used during application + /// startup to control how the Sessions context is set up. + public Action? Sessions { get; set; } + + /// + /// Gets or sets the delegate used to configure options for the Tokens DbContext. + /// If not set, default option will implement. + /// + /// Assign a delegate to customize the configuration of the Tokens DbContext, such as + /// specifying the database provider or connection string. This property is typically used during application + /// startup to control how the Tokens context is set up. + public Action? Tokens { get; set; } + + /// + /// Gets or sets the delegate used to configure options for the Authentication DbContext. + /// If not set, default option will implement. + /// + /// Assign a delegate to customize the configuration of the Authentication DbContext, such as + /// specifying the database provider or connection string. This property is typically used during application + /// startup to control how the Authentication context is set up. + public Action? Authentication { get; set; } + + internal Action Resolve(Action? specific) + => specific ?? Default ?? throw new InvalidOperationException("No database configuration provided for UltimateAuth EFCore. Use options.Default or configure specific DbContext options."); + } +} diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UltimateAuthEntityFrameworkCoreExtensions.cs b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UltimateAuthEntityFrameworkCoreExtensions.cs new file mode 100644 index 00000000..f5efa97b --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UltimateAuthEntityFrameworkCoreExtensions.cs @@ -0,0 +1,103 @@ +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Reference.Bundle; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +/// +/// Provides extension methods for registering UltimateAuth with Entity Framework Core-based persistence using reference +/// domain implementations. +/// +public static class UltimateAuthEntityFrameworkCoreExtensions +{ + /// + /// Registers UltimateAuth with Entity Framework Core based persistence using reference domain implementations. + /// + /// The service collection. + /// + /// A delegate used to configure the for all UltimateAuth DbContexts. + /// + /// This is required and must specify a database provider such as: + /// + /// UseSqlServer + /// UseNpgsql + /// UseSqlite + /// + /// + /// The updated . + /// + /// This method provides a complete UltimateAuth setup including: + /// + /// Reference domain implementations + /// Entity Framework Core persistence + /// + /// + /// No additional reference packages are required. + /// + /// This method wires up all Entity Framework Core stores along with reference domain implementations. + /// + /// Example: + /// + /// services.AddUltimateAuthServer() + /// .AddUltimateAuthEntityFrameworkCore(options => + /// { + /// options.UseSqlServer("connection-string"); + /// }); + /// + /// + /// Note: + /// This method does not configure migrations automatically. You are responsible for managing migrations. + /// + public static IServiceCollection AddUltimateAuthEntityFrameworkCore(this IServiceCollection services, Action configureDb) + { + services + .AddUltimateAuthReferences() + .AddUltimateAuthUsersEntityFrameworkCore(configureDb) + .AddUltimateAuthCredentialsEntityFrameworkCore(configureDb) + .AddUltimateAuthAuthorizationEntityFrameworkCore(configureDb) + .AddUltimateAuthSessionsEntityFrameworkCore(configureDb) + .AddUltimateAuthTokensEntityFrameworkCore(configureDb) + .AddUltimateAuthAuthenticationEntityFrameworkCore(configureDb); + + return services; + } + + /// + /// Adds and configures Entity Framework Core-based UltimateAuth services and related references to the specified + /// service collection. + /// + /// + /// This method provides a complete UltimateAuth setup including: + /// + /// Reference domain implementations + /// Entity Framework Core persistence + /// + /// + /// No additional reference packages are required. + /// + /// The service collection to which the UltimateAuth Entity Framework Core services and references will be added. + /// A delegate that configures the options for UltimateAuth Entity Framework Core integration. + /// The same service collection instance, enabling method chaining. + public static IServiceCollection AddUltimateAuthEntityFrameworkCore(this IServiceCollection services, Action configure) + { + var options = new UAuthEfCoreOptions(); + configure(options); + + services + .AddUltimateAuthReferences() + .AddUltimateAuthUsersEntityFrameworkCore(options.Resolve(options.Users)) + .AddUltimateAuthCredentialsEntityFrameworkCore(options.Resolve(options.Credentials)) + .AddUltimateAuthAuthorizationEntityFrameworkCore(options.Resolve(options.Authorization)) + .AddUltimateAuthSessionsEntityFrameworkCore(options.Resolve(options.Sessions)) + .AddUltimateAuthTokensEntityFrameworkCore(options.Resolve(options.Tokens)) + .AddUltimateAuthAuthenticationEntityFrameworkCore(options.Resolve(options.Authentication)); + + return services; + } +} diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/logo.png b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/nuget/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.Bundle.csproj b/nuget/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.Bundle.csproj new file mode 100644 index 00000000..ee75955b --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.Bundle.csproj @@ -0,0 +1,36 @@ + + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.InMemory.Bundle + + + Provides a complete in-memory setup for UltimateAuth. + This package includes reference domain implementations amd in memory persistence for all modules. + It is ideal for development, testing, and prototyping scenarios. + No external database is required. + + + authentication;authorization;identity;efcore;inmemory;bundle;auth-framework;security;jwt + logo.png + README.md + + + + + + + + + + + + + + + + + + diff --git a/nuget/CodeBeam.UltimateAuth.InMemory/README.md b/nuget/CodeBeam.UltimateAuth.InMemory/README.md new file mode 100644 index 00000000..7d8be5f4 --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.InMemory/README.md @@ -0,0 +1,26 @@ +# UltimateAuth InMemory Bundle + +Provides a complete in-memory setup for UltimateAuth. + +## 🚀 Quick Start + +```csharp +builder.Services + .AddUltimateAuthServer() + .AddUltimateAuthInMemory(); +``` + +## 📦 Includes + +- Reference domain implementations +- In-memory persistence for all modules + +## 🎯 Use Cases + +- Development +- Testing +- Prototyping + +## ⚠️ Warning + +Data is not persisted. Do not use in production. \ No newline at end of file diff --git a/nuget/CodeBeam.UltimateAuth.InMemory/UltimateAuthInMemoryExtensions.cs b/nuget/CodeBeam.UltimateAuth.InMemory/UltimateAuthInMemoryExtensions.cs new file mode 100644 index 00000000..2d42f4c9 --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.InMemory/UltimateAuthInMemoryExtensions.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; +using CodeBeam.UltimateAuth.Users.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Sessions.InMemory.Extensions; +using CodeBeam.UltimateAuth.Tokens.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authentication.InMemory.Extensions; +using CodeBeam.UltimateAuth.Reference.Bundle; + +namespace CodeBeam.UltimateAuth.InMemory; + +/// +/// Provides extension methods for registering in-memory implementations of UltimateAuth user, credential, +/// authorization, session, token, and authentication services, along with their reference services, in the dependency +/// injection container. +/// +/// These methods are intended for scenarios such as testing or development where in-memory storage is +/// sufficient. For production environments, consider using persistent storage implementations. +public static class UltimateAuthInMemoryExtensions +{ + /// + /// Registers in-memory implementations of UltimateAuth user, credential, authorization, session, token, and + /// authentication services, along with their reference services, in the dependency injection container. + /// + /// This method is intended for scenarios such as testing or development where in-memory storage + /// is sufficient. For production environments, consider using persistent storage implementations. + /// The service collection to which the in-memory UltimateAuth services will be added. + /// The same instance of that was provided, to support method chaining. + public static IServiceCollection AddUltimateAuthInMemory(this IServiceCollection services) + { + services + .AddUltimateAuthReferences() + .AddUltimateAuthUsersInMemory() + .AddUltimateAuthCredentialsInMemory() + .AddUltimateAuthAuthorizationInMemory() + .AddUltimateAuthSessionsInMemory() + .AddUltimateAuthTokensInMemory() + .AddUltimateAuthAuthenticationInMemory(); + + return services; + } +} \ No newline at end of file diff --git a/nuget/CodeBeam.UltimateAuth.InMemory/logo.png b/nuget/CodeBeam.UltimateAuth.InMemory/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/nuget/CodeBeam.UltimateAuth.Reference.Bundle/CodeBeam.UltimateAuth.Reference.Bundle.csproj b/nuget/CodeBeam.UltimateAuth.Reference.Bundle/CodeBeam.UltimateAuth.Reference.Bundle.csproj new file mode 100644 index 00000000..5c921100 --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.Reference.Bundle/CodeBeam.UltimateAuth.Reference.Bundle.csproj @@ -0,0 +1,33 @@ + + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Reference.Bundle + + + Provides a bundled setup of all UltimateAuth reference implementations. + It wires together domain-level orchestration, validation, and endpoint integrations required by UltimateAuth Server. + This package is intended for developers who want a ready-to-use, fully functional authentication domain setup without manually registering each reference module. + For advanced scenarios, individual reference packages can be installed and configured separately. + + + authentication;authorization;identity;reference;bundle;modular;auth-framework;users;credentials;policies + logo.png + README.md + + + + + + + + + + + + + + + diff --git a/nuget/CodeBeam.UltimateAuth.Reference.Bundle/README.md b/nuget/CodeBeam.UltimateAuth.Reference.Bundle/README.md new file mode 100644 index 00000000..52e6cb9d --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.Reference.Bundle/README.md @@ -0,0 +1,56 @@ +# UltimateAuth Reference Bundle + +Provides a bundled setup of all UltimateAuth reference implementations. + +## 📦 Included Modules + +This package registers the default reference behavior for: + +- Users +- Credentials +- Authorization + +## 🚀 Quick Start + +```csharp +builder.Services + .AddUltimateAuthServer() + .AddUltimateAuthReferences(); +``` + +## 🧩 What This Does + +This package wires together: + +- Domain orchestration +- Validation pipelines +- Endpoint integrations +- Default application behavior + +It allows you to get a fully working authentication system with minimal setup. + +## ⚠️ Persistence Required + +This package does NOT include persistence. + +You must add one of the following: + +- InMemory (for development) +`builder.Services.AddUltimateAuthInMemory();` + +- Entity Framework Core (for production) +`builder.Services.AddUltimateAuthEntityFrameworkCore();` + +## 🔧 Advanced Usage + +If you need fine-grained control, you can install individual reference packages: + +- CodeBeam.UltimateAuth.Users.Reference +- CodeBeam.UltimateAuth.Credentials.Reference +- CodeBeam.UltimateAuth.Authorization.Reference + +## 🧠 Concept + +Reference packages define the default domain behavior of UltimateAuth. + +This bundle provides a ready-to-use composition of those behaviors. \ No newline at end of file diff --git a/nuget/CodeBeam.UltimateAuth.Reference.Bundle/UltimateAuthReferenceBundleExtensions.cs b/nuget/CodeBeam.UltimateAuth.Reference.Bundle/UltimateAuthReferenceBundleExtensions.cs new file mode 100644 index 00000000..fd58b908 --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.Reference.Bundle/UltimateAuthReferenceBundleExtensions.cs @@ -0,0 +1,54 @@ +using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Reference.Extensions; +using CodeBeam.UltimateAuth.Security.Argon2; +using CodeBeam.UltimateAuth.Users.Reference.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Reference.Bundle; + +public static class UltimateAuthReferenceBundleExtensions +{ + /// + /// Registers all UltimateAuth reference implementations in a single step. + /// + /// + /// This method adds the default reference behavior for: + /// + /// Users + /// Credentials + /// Authorization + /// + /// + /// It is intended as a convenience bundle for quickly enabling a fully working + /// domain layer on top of UltimateAuth Server. + /// + /// + /// This package does not provide persistence. To complete the setup, you must also + /// register a persistence provider such as: + /// + /// + /// InMemory + /// EntityFrameworkCore + /// + /// + /// + /// Advanced users can skip this bundle and register individual reference packages for finer control. + /// + /// + /// The service collection. + /// The same instance for chaining. + public static IServiceCollection AddUltimateAuthReferences(this IServiceCollection services) + { + services + .AddUltimateAuthUsersReference() + .AddUltimateAuthCredentialsReference() + .AddUltimateAuthAuthorizationReference(); + + services.TryAddSingleton(); + services.AddOptions(); + + return services; + } +} diff --git a/nuget/CodeBeam.UltimateAuth.Reference.Bundle/logo.png b/nuget/CodeBeam.UltimateAuth.Reference.Bundle/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index fd8d083e..fbf939d6 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -5,6 +5,7 @@ enable enable 0.0.1 + false true @@ -16,22 +17,10 @@ - - - - - + + - - - - - - - - - diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor index f9989cb9..71805ad6 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor @@ -24,7 +24,7 @@ - + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index d8673b17..219617bf 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -16,7 +16,7 @@ @inject IHubFlowReader HubFlowReader @inject IHubCredentialResolver HubCredentialResolver @inject IAuthStore AuthStore -@inject IBrowserStorage BrowserStorage +@inject IClientStorage BrowserStorage @inject IUAuthFlowService Flow @inject ISnackbar Snackbar @inject IFlowCredentialResolver CredentialResolver diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor index 9e918850..22f83d10 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor @@ -3,14 +3,28 @@ @inject ISnackbar Snackbar @inject DarkModeManager DarkModeManager - - + + + + + + + + + + + + + + + @* Advanced: you can fully control routing by providing your own Router *@ + @* - + @@ -20,8 +34,8 @@ - - + *@ + @code { private async Task HandleReauth() diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor index 09765c2c..aada4df3 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor @@ -11,6 +11,7 @@ @using CodeBeam.UltimateAuth.Sample.UAuthHub.Components @using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout @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 index f8f05cb4..95b63347 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Infrastructure/DarkModeManager.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Infrastructure/DarkModeManager.cs @@ -7,9 +7,9 @@ public sealed class DarkModeManager { private const string StorageKey = "uauth:theme:dark"; - private readonly IBrowserStorage _storage; + private readonly IClientStorage _storage; - public DarkModeManager(IBrowserStorage storage) + public DarkModeManager(IClientStorage storage) { _storage = storage; } diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index b0412644..d5c689a8 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -1,21 +1,13 @@ -using CodeBeam.UltimateAuth.Authentication.InMemory; -using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; -using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; -using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.Runtime; -using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; -using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; using CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Sessions.InMemory; -using CodeBeam.UltimateAuth.Tokens.InMemory; -using CodeBeam.UltimateAuth.Users.InMemory.Extensions; -using CodeBeam.UltimateAuth.Users.Reference.Extensions; using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; @@ -43,18 +35,9 @@ //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); }) - .AddUltimateAuthUsersInMemory() - .AddUltimateAuthUsersReference() - .AddUltimateAuthCredentialsInMemory() - .AddUltimateAuthCredentialsReference() - .AddUltimateAuthAuthorizationInMemory() - .AddUltimateAuthAuthorizationReference() - .AddUltimateAuthInMemorySessions() - .AddUltimateAuthInMemoryTokens() - .AddUltimateAuthInMemoryAuthenticationSecurity() - .AddUltimateAuthArgon2(); - -builder.Services.AddUltimateAuthClient(o => + .AddUltimateAuthInMemory(); + +builder.Services.AddUltimateAuthClientBlazor(o => { //o.Refresh.Interval = TimeSpan.FromSeconds(5); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; @@ -106,7 +89,7 @@ app.MapControllers(); app.MapRazorComponents() .AddInteractiveServerRenderMode() - .AddUltimateAuthClientRoutes(typeof(UAuthClientMarker).Assembly); + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); app.MapGet("/health", () => { 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 index 7cc56f81..c27fc66a 100644 --- 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 @@ -4,33 +4,20 @@ net10.0 enable enable - 0.0.1 + false - + - - - - - - + + - - - - - - - - - diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor index 24f946d8..087e4279 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor @@ -19,7 +19,7 @@ - + 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 index 64797ba4..2981e9c5 100644 --- 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 @@ -40,7 +40,7 @@ You can still active your account later. } else { - Snackbar.Add(result?.GetErrorText ?? "Delete failed.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); } } @@ -71,7 +71,7 @@ This action can't be undone.

} else { - Snackbar.Add(result?.GetErrorText ?? "Delete failed.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); } } } 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 index bb7998b1..5986ae68 100644 --- 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 @@ -41,7 +41,7 @@ private async Task CreateUserAsync() if (!result.IsSuccess) { - Snackbar.Add(result.GetErrorText ?? "User creation failed.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "User creation failed.", Severity.Error); return; } 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 index 1f207f8d..f81d2b1b 100644 --- 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 @@ -82,7 +82,7 @@ private async Task ChangePasswordAsync() } else { - Snackbar.Add(result.GetErrorText ?? "An error occurred while changing password", Severity.Error); + Snackbar.Add(result.ErrorText ?? "An error occurred while changing password", Severity.Error); } } 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 index 4c789ba6..e14d8671 100644 --- 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 @@ -139,7 +139,7 @@ private async Task CommittedItemChanges(UserIdentifierIn } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to update identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to update identifier", Severity.Error); } await ReloadAsync(); @@ -180,7 +180,7 @@ private async Task AddNewIdentifier() } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to add identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to add identifier", Severity.Error); } } @@ -223,7 +223,7 @@ This will only mark the identifier as verified in UltimateAuth. } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to verify primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier", Severity.Error); } } @@ -249,7 +249,7 @@ private async Task SetPrimaryAsync(Guid id) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to set primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier", Severity.Error); } } @@ -275,7 +275,7 @@ private async Task UnsetPrimaryAsync(Guid id) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to unset primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier", Severity.Error); } } @@ -301,7 +301,7 @@ private async Task DeleteIdentifier(Guid id) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to delete identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to delete identifier", Severity.Error); } } 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 index 844afd4f..16cf08fd 100644 --- 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 @@ -72,7 +72,7 @@ private async Task Save() if (!result.IsSuccess) { - Snackbar.Add(result.GetErrorText ?? "Failed to update permissions", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to update permissions", Severity.Error); return; } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs index 5b861925..364a3eb3 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs @@ -106,7 +106,7 @@ private async Task SaveAsync() } else { - Snackbar.Add(result.GetErrorText ?? "Failed to update profile", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to update profile", Severity.Error); } } 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 index c719539b..c66f8adc 100644 --- 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 @@ -30,7 +30,7 @@ private async Task RequestResetAsync() var result = await UAuthClient.Credentials.BeginResetMyAsync(request); if (!result.IsSuccess || result.Value is null) { - Snackbar.Add(result.GetErrorText ?? "Failed to request credential reset.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); return; } 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 index d77de014..75a673ad 100644 --- 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 @@ -33,7 +33,7 @@ private async Task> LoadServerData(GridState state, if (!res.IsSuccess || res.Value == null) { - Snackbar.Add(res.GetErrorText ?? "Failed", Severity.Error); + Snackbar.Add(res.ErrorText ?? "Failed", Severity.Error); return new GridData { @@ -64,7 +64,7 @@ private async Task CommittedItemChanges(RoleInfo role) } else { - Snackbar.Add(result.GetErrorText ?? "Rename failed", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Rename failed", Severity.Error); } await ReloadAsync(); @@ -93,7 +93,7 @@ private async Task CreateRole() } else { - Snackbar.Add(res.GetErrorText ?? "Creation failed.", Severity.Error); + Snackbar.Add(res.ErrorText ?? "Creation failed.", Severity.Error); } } @@ -119,7 +119,7 @@ private async Task DeleteRole(RoleId roleId) } else { - Snackbar.Add(result.GetErrorText ?? "Deletion failed.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Deletion failed.", Severity.Error); } } 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 index bcf2bb77..f5d5822f 100644 --- 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 @@ -133,7 +133,7 @@ private async Task LogoutAllAsync() } else { - Snackbar.Add(result.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -147,7 +147,7 @@ private async Task LogoutOthersAsync() } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to logout", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to logout", Severity.Error); } } @@ -177,7 +177,7 @@ private async Task LogoutDeviceAsync(SessionChainId chainId) } else { - Snackbar.Add(result.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -203,7 +203,7 @@ private async Task RevokeAllAsync() } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -217,7 +217,7 @@ private async Task RevokeOthersAsync() } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -247,7 +247,7 @@ private async Task RevokeChainAsync(SessionChainId chainId) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -270,7 +270,7 @@ private async Task ShowChainDetailsAsync(SessionChainId chainId) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to fetch chain details.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to fetch chain details.", Severity.Error); _chainDetail = null; } } 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 index f856566a..0e131d3b 100644 --- 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 @@ -78,7 +78,7 @@ private async Task ChangeStatusAsync() } else { - Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); } } 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 index eb3557c4..ac927232 100644 --- 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 @@ -58,7 +58,7 @@ private async Task AddRole() } else { - Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); } _selectedRole = null; @@ -104,7 +104,7 @@ private async Task RemoveRole(string role) } else { - Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); } } 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 index ff112f29..7fc91cc2 100644 --- 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 @@ -40,7 +40,7 @@ private async Task> LoadUsers(GridState state if (!res.IsSuccess || res.Value == null) { - Snackbar.Add(res.GetErrorText ?? "Failed to load users.", Severity.Error); + Snackbar.Add(res.ErrorText ?? "Failed to load users.", Severity.Error); return new GridData { @@ -153,7 +153,7 @@ Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.Pr } else { - Snackbar.Add(result.GetErrorText ?? "Failed to delete user.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to delete user.", Severity.Error); } } 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 index 844f6483..b25944b1 100644 --- 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 @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Client.Errors; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs index f4747658..29f37744 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Client.Runtime; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -80,7 +80,6 @@ AuthFailureReason.InvalidCredentials when remainingAttempts is > 0 private async Task ProgrammaticLogin() { - var deviceId = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { Identifier = "admin", 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 index 9a193aa8..d1e67865 100644 --- 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 @@ -35,11 +35,11 @@ private async Task HandleRegisterAsync() var result = await UAuthClient.Users.CreateAsync(request); if (result.IsSuccess) { - Snackbar.Add("User created succesfully.", Severity.Success); + Snackbar.Add("User created successfully.", Severity.Success); } else { - Snackbar.Add(result.GetErrorText ?? "Failed to create user.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to create user.", Severity.Error); } } } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor index 03c4e497..6586c3fc 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor @@ -3,14 +3,28 @@ @inject ISnackbar Snackbar @inject DarkModeManager DarkModeManager - - + + + + + + + + + + + + + + + @* Advanced: you can fully control routing by providing your own Router *@ + @* - + @@ -20,8 +34,8 @@ - - + *@ + @code { private async Task HandleReauth() diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor index 877928bb..2c3cb6dd 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor @@ -16,6 +16,7 @@ @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 index 7b2c1990..9afcf32f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DarkModeManager.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DarkModeManager.cs @@ -7,9 +7,9 @@ public sealed class DarkModeManager { private const string StorageKey = "uauth:theme:dark"; - private readonly IBrowserStorage _storage; + private readonly IClientStorage _storage; - public DarkModeManager(IBrowserStorage storage) + public DarkModeManager(IClientStorage storage) { _storage = storage; } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index dee4c413..5995bf9f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,27 +1,20 @@ -using CodeBeam.UltimateAuth.Authentication.InMemory; -using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; -using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; -using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; -using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; -using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Sessions.InMemory; -using CodeBeam.UltimateAuth.Tokens.InMemory; -using CodeBeam.UltimateAuth.Users.InMemory.Extensions; -using CodeBeam.UltimateAuth.Users.Reference.Extensions; using Microsoft.AspNetCore.HttpOverrides; using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; +using CodeBeam.UltimateAuth.Client.Blazor; var builder = WebApplication.CreateBuilder(args); +#region Core + builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddCircuitOptions(options => @@ -29,12 +22,20 @@ options.DetailedErrors = true; }); +builder.Services.AddOpenApi(); + +#endregion + +# region UI & MudBlazor & Extensions + builder.Services.AddMudServices(o => { o.SnackbarConfiguration.PreventDuplicates = false; }); builder.Services.AddMudExtensions(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddOpenApi(); +builder.Services.AddScoped(); + +#endregion + builder.Services.AddUltimateAuthServer(o => { @@ -49,25 +50,15 @@ o.Login.LockoutDuration = TimeSpan.FromSeconds(10); o.Identifiers.AllowMultipleUsernames = true; }) - .AddUltimateAuthUsersInMemory() - .AddUltimateAuthUsersReference() - .AddUltimateAuthCredentialsInMemory() - .AddUltimateAuthCredentialsReference() - .AddUltimateAuthAuthorizationInMemory() - .AddUltimateAuthAuthorizationReference() - .AddUltimateAuthInMemorySessions() - .AddUltimateAuthInMemoryTokens() - .AddUltimateAuthInMemoryAuthenticationSecurity() - .AddUltimateAuthArgon2(); - -builder.Services.AddUltimateAuthClient(o => + .AddUltimateAuthInMemory(); + +builder.Services.AddUltimateAuthClientBlazor(o => { //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; }); -builder.Services.AddScoped(); builder.Services.Configure(options => { @@ -76,6 +67,7 @@ ForwardedHeaders.XForwardedProto; }); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) @@ -104,6 +96,6 @@ app.MapUltimateAuthEndpoints(); app.MapRazorComponents() .AddInteractiveServerRenderMode() - .AddUltimateAuthClientRoutes(typeof(UAuthClientMarker).Assembly); + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); app.Run(); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor index 7d8ad8a5..783b707b 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor @@ -1,16 +1,30 @@ -@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure -@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages +@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 *@ + @* - + @@ -20,8 +34,8 @@ - - + *@ + @code { private async Task HandleReauth() 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 index 55885729..06ddc3f2 100644 --- 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 @@ -4,24 +4,21 @@ net10.0 enable enable - 0.0.1 + 0.1.0 + false - - - - + + + + - - - - - + 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 index eef2e773..a4f18940 100644 --- 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 @@ -40,7 +40,7 @@ You can still active your account later. } else { - Snackbar.Add(result?.GetErrorText ?? "Delete failed.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); } } @@ -71,7 +71,7 @@ This action can't be undone.

} else { - Snackbar.Add(result?.GetErrorText ?? "Delete failed.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); } } } 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 index ccff3139..a1ac5b5c 100644 --- 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 @@ -41,7 +41,7 @@ private async Task CreateUserAsync() if (!result.IsSuccess) { - Snackbar.Add(result.GetErrorText ?? "User creation failed.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "User creation failed.", Severity.Error); return; } 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 index c26bb246..79568ae4 100644 --- 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 @@ -82,7 +82,7 @@ private async Task ChangePasswordAsync() } else { - Snackbar.Add(result.GetErrorText ?? "An error occurred while changing password", Severity.Error); + Snackbar.Add(result.ErrorText ?? "An error occurred while changing password", Severity.Error); } } 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 index 7838af7f..3ba6b5a4 100644 --- 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 @@ -141,7 +141,7 @@ private async Task CommittedItemChanges(UserIdentifierIn } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to update identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to update identifier", Severity.Error); } await ReloadAsync(); @@ -182,7 +182,7 @@ private async Task AddNewIdentifier() } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to add identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to add identifier", Severity.Error); } } @@ -225,7 +225,7 @@ This will only mark the identifier as verified in UltimateAuth. } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to verify primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier", Severity.Error); } } @@ -251,7 +251,7 @@ private async Task SetPrimaryAsync(Guid id) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to set primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier", Severity.Error); } } @@ -277,7 +277,7 @@ private async Task UnsetPrimaryAsync(Guid id) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to unset primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier", Severity.Error); } } @@ -303,7 +303,7 @@ private async Task DeleteIdentifier(Guid id) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to delete identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to delete identifier", Severity.Error); } } 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 index 214c690b..5fad228d 100644 --- 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 @@ -72,7 +72,7 @@ private async Task Save() if (!result.IsSuccess) { - Snackbar.Add(result.GetErrorText ?? "Failed to update permissions", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to update permissions", Severity.Error); return; } diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs index ccd61162..2117a915 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs @@ -108,7 +108,7 @@ private async Task SaveAsync() } else { - Snackbar.Add(result.GetErrorText ?? "Failed to update profile", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to update profile", Severity.Error); } } 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 index 5d6c99a7..4f5fb818 100644 --- 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 @@ -30,7 +30,7 @@ private async Task RequestResetAsync() var result = await UAuthClient.Credentials.BeginResetMyAsync(request); if (!result.IsSuccess || result.Value is null) { - Snackbar.Add(result.GetErrorText ?? "Failed to request credential reset.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); return; } 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 index 298c87b0..712e351a 100644 --- 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 @@ -45,7 +45,7 @@ private async Task> LoadServerData(GridState state, if (!res.IsSuccess || res.Value == null) { - Snackbar.Add(res.GetErrorText ?? "Failed", Severity.Error); + Snackbar.Add(res.ErrorText ?? "Failed", Severity.Error); return new GridData { @@ -76,7 +76,7 @@ private async Task CommittedItemChanges(RoleInfo role) } else { - Snackbar.Add(result.GetErrorText ?? "Rename failed", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Rename failed", Severity.Error); } await ReloadAsync(); @@ -105,7 +105,7 @@ private async Task CreateRole() } else { - Snackbar.Add(res.GetErrorText ?? "Creation failed.", Severity.Error); + Snackbar.Add(res.ErrorText ?? "Creation failed.", Severity.Error); } } @@ -131,7 +131,7 @@ private async Task DeleteRole(RoleId roleId) } else { - Snackbar.Add(result.GetErrorText ?? "Deletion failed.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Deletion failed.", Severity.Error); } } 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 index 3793677a..b6179488 100644 --- 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 @@ -135,7 +135,7 @@ private async Task LogoutAllAsync() } else { - Snackbar.Add(result.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -149,7 +149,7 @@ private async Task LogoutOthersAsync() } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to logout", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to logout", Severity.Error); } } @@ -179,7 +179,7 @@ private async Task LogoutDeviceAsync(SessionChainId chainId) } else { - Snackbar.Add(result.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -205,7 +205,7 @@ private async Task RevokeAllAsync() } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -219,7 +219,7 @@ private async Task RevokeOthersAsync() } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -249,7 +249,7 @@ private async Task RevokeChainAsync(SessionChainId chainId) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); } } @@ -272,7 +272,7 @@ private async Task ShowChainDetailsAsync(SessionChainId chainId) } else { - Snackbar.Add(result?.GetErrorText ?? "Failed to fetch chain details.", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to fetch chain details.", Severity.Error); _chainDetail = null; } } 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 index d5046440..c917f086 100644 --- 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 @@ -78,7 +78,7 @@ private async Task ChangeStatusAsync() } else { - Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); } } 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 index a3b2fa0c..349361a4 100644 --- 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 @@ -58,7 +58,7 @@ private async Task AddRole() } else { - Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); } _selectedRole = null; @@ -104,7 +104,7 @@ private async Task RemoveRole(string role) } else { - Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); } } 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 index 64dcbf6c..31aa7e68 100644 --- 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 @@ -52,7 +52,7 @@ private async Task> LoadUsers(GridState state if (!res.IsSuccess || res.Value == null) { - Snackbar.Add(res.GetErrorText ?? "Failed to load users.", Severity.Error); + Snackbar.Add(res.ErrorText ?? "Failed to load users.", Severity.Error); return new GridData { @@ -165,7 +165,7 @@ Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.Pr } else { - Snackbar.Add(result.GetErrorText ?? "Failed to delete user.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to delete user.", Severity.Error); } } 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 index bd8900e4..de933317 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Infrastructure/DarkModeManager.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Infrastructure/DarkModeManager.cs @@ -7,9 +7,9 @@ public sealed class DarkModeManager { private const string StorageKey = "uauth:theme:dark"; - private readonly IBrowserStorage _storage; + private readonly IClientStorage _storage; - public DarkModeManager(IBrowserStorage storage) + public DarkModeManager(IClientStorage storage) { _storage = storage; } 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 index 0a44f4fd..f734b4b8 100644 --- 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 @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Client.Errors; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs index 015076b6..459081f1 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Client.Runtime; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; 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 index 3486ae47..db73fd6a 100644 --- 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 @@ -35,11 +35,11 @@ private async Task HandleRegisterAsync() var result = await UAuthClient.Users.CreateAsync(request); if (result.IsSuccess) { - Snackbar.Add("User created succesfully.", Severity.Success); + Snackbar.Add("User created successfully.", Severity.Success); } else { - Snackbar.Add(result.GetErrorText ?? "Failed to create user.", Severity.Error); + Snackbar.Add(result.ErrorText ?? "Failed to create user.", Severity.Error); } } } diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 0412ee70..53a18e6b 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm; @@ -15,7 +15,7 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddUltimateAuth(); -builder.Services.AddUltimateAuthClient(o => +builder.Services.AddUltimateAuthClientBlazor(o => { o.Endpoints.BasePath = "https://localhost:6110/auth"; o.Reauth.Behavior = ReauthBehavior.RaiseEvent; diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor index 7fe2bd88..9eaccb01 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor @@ -16,6 +16,7 @@ @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/index.html b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html index 879067ed..6499fa41 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html @@ -32,7 +32,7 @@ - + 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 index 4f62ba6c..1c868dd7 100644 --- 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 @@ -4,14 +4,15 @@ net10.0 enable enable + false - + - + diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs index e5da6274..ebb830f7 100644 --- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Server.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -9,17 +10,7 @@ // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); -builder.Services.AddUltimateAuth(); - -builder.Services.AddAuthorization(options => -{ - options.AddPolicy("ApiUser", policy => - { - policy.RequireAuthenticatedUser(); - policy.RequireClaim("scope", "api"); - // veya role, veya custom claim - }); -}); +builder.Services.AddUltimateAuthResourceApi(); builder.Services.AddCors(options => { diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs deleted file mode 100644 index ed025e7c..00000000 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Components; - -namespace CodeBeam.UltimateAuth.Client; - -internal sealed class UAuthCascadingStateProvider : CascadingValueSource, IDisposable -{ - private readonly IUAuthStateManager _stateManager; - - public UAuthCascadingStateProvider(IUAuthStateManager stateManager) : base(() => stateManager.State, isFixed: false) - { - _stateManager = stateManager; - _stateManager.State.Changed += OnStateChanged; - } - - private void OnStateChanged(UAuthStateChangeReason _) - { - NotifyChangedAsync(); - } - - public void Dispose() - { - _stateManager.State.Changed -= OnStateChanged; - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor deleted file mode 100644 index 47a45bad..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor +++ /dev/null @@ -1,22 +0,0 @@ -@namespace CodeBeam.UltimateAuth.Client - -@using CodeBeam.UltimateAuth.Client.Contracts -@using Microsoft.AspNetCore.Components.Authorization -@inject IUAuthStateManager StateManager -@inject IUAuthClientBootstrapper Bootstrapper -@inject ISessionCoordinator Coordinator - - - - @if (RenderMode == UAuthRenderMode.Reactive) - { - - @ChildContent - - } - else - { - @ChildContent - } - - diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientMarker.cs b/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientMarker.cs deleted file mode 100644 index 1c9c2333..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientMarker.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace CodeBeam.UltimateAuth.Client; - -public class UAuthClientMarker -{ -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStore.cs similarity index 50% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStore.cs index fef7743c..e02e6a66 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStore.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Security; namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface IAuthenticationSecurityStateStore { - Task GetAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); + 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(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, 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/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/Stores/IRefreshTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs index fb8693c1..095beb5e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -5,7 +5,6 @@ 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); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 53fd7b31..fc29ea73 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -20,8 +20,8 @@ public interface ISessionStore 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(TenantKey tenant, UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); - Task RevokeAllChainsAsync(TenantKey tenant, UserKey user, 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); @@ -34,7 +34,7 @@ public interface ISessionStore Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default); Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default); - Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, 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/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index 2f9b3da7..a7a9e51d 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -6,4 +6,5 @@ [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 4e15f94d..1b60c23c 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -2,18 +2,25 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - 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/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index e1bf2aaa..0ca7c003 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -11,7 +11,7 @@ public class UAuthResult public HttpStatusInfo Http => new(Status); - public string? GetErrorText => Problem?.Detail ?? Problem?.Title; + public string? ErrorText => Problem?.Detail ?? Problem?.Title; public sealed class HttpStatusInfo { diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs index f3d3049c..4c00e82c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -1,5 +1,9 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; +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; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs index 7da55a17..18800f17 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs @@ -1,7 +1,10 @@ -using System.Security; +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; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs index 501db9ae..b3cf195e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs @@ -1,11 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Domain; +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 +public sealed class AuthenticationSecurityState : ITenantEntity, IVersionedEntity, IEntitySnapshot { public Guid Id { get; } public TenantKey Tenant { get; } @@ -29,6 +30,13 @@ public sealed class AuthenticationSecurityState 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, @@ -425,4 +433,60 @@ public AuthenticationSecurityState ClearReset() 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/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs index ea8e2fea..779222b7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs @@ -74,7 +74,6 @@ private static IServiceCollection AddUltimateAuthInternal(this IServiceCollectio services.AddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); return services; } 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/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/logo.png b/src/CodeBeam.UltimateAuth.Core/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 zcmc&Xc{tSF_xCdxBiqO_iq{O4lwu2DSb;A%=F=K0+c{+32sv-c&N$~BD)Y9%>?&y> zYGCoVO#?RugJtI6?Oj4Xikq_##eExOu8#z~sJ`qwn_V95fXleF?#0s0`#q&cntE&{ zK55kr=c5yGn?Vnb{ahD{i-&@Oj@29x{I~| z%Rk>{!TK(Pw$CBSFiRt)Rdhn7l#WkGuW&mn%)<*8=ft-4vFy6s+vBM@AfTpm9=#I# z_ir~V$)f(``KpAok7R%?KMxzKZi%>38gID@G!89Uq{gUNdTfWiu4=4PtnU9R1*`2f ze#5fNLm&R(75n_%#6E7_D$h1J3cfn{={y@=kccUx7S|%P)qJDKt1tuL6 z4{HXr+cB&HWu=Ed-YV*f_?bc23iM<58(B&6pwre^i{SPTD6s}D(vHYh!l()6oQ8rA zH~~l!yb{8eeKeO_o;!&bhCNgj6dA%mXet28I{;`w0M3E`jw*fC=H(Le%r#``^w+V; zcGZt_zu)w#nwipjvS9P5LAz2C#zE{~cK0>fr@i`}UKKU<+|%dlL|@dmS1fib_mjvo zPSUj3W%&}QVtWvMQ0kA=*Y0`fTg2R%8tdidv|UC^&AU8(4lFjbWvbgyOc)2KNpsz6 z#gv?I_toDkJS)8W03Qj`pDw>rT^b(J{A50P>rQuhP<~LXA%cpSbH}4sT;9|UI$)$= z80@c)g@UQ#prNI|G!$T%hj?@2=Cn1?tKO@RNC60YyVW04!T7#IeTXke05$NJuLqac zX!UCBgJCf6I+C4I0{0J&{InU;Z{PjW^jo`q<|Q*!Q&%+H*yuJ0Iq zKYMY#-@)kDa{7^VpP(JTh@|@9gK@j5mTR8}e3vazM3n!<;WPKu-oT>QxYK!=-F_Lj zJ4(8zJ1+6&%kP)&4NBP15C5S}kvA}5&ojFc3tHa!9dnt_Lc$?}^T(l~EN2u13=aWt zRR&P~gu$TVzbO3sV@#s0xxUQC0mJ!j0bvQBkNrV+tI03%k$r{%5*wBk-W}<%css!J z5=UmK%qCxRCCwk7%OXgp#HKC_3jG>v4;oMgV&MG)Jc|=`h-_xdM<=sLci;Y5iV1%8 zFAJr7#X~@mnczomAxvy}!^We$|Ig^yjGauxsh*o5luu#WW;xWtmC=^0zlu%P#*8NC z(kT74pTCZDx>^CnqJ+DB!M8ryM@&04yvb93AJoW;nX30@Y31eaVM*l9X{MdQ*WWqv8l2+O~Bg3TYlN;#aT@JLXy5AopL zF-0i|$>>PdUU4iF@gvK7%b2z&;y>vj1K{3Ns#b^A<0oBp0fi3{8%Ud$&h8k|Uj5!f zh$|c67&6`2xcahv{@C3h8_PsnAgpf|aWiI8C?Gqa53>=Im#-IV^Bq~qwF$Li&vpQ{ zH@+tQGbzY>wZFDy*#Vpm0_rp{4??0LWcq->E_cP6NgVbzlN_xX(;KgGT@9NMiKysV?5pKJn0Hs--I6f{nOg!sU_}alo$0^APDUCoHQyaD0F2$uKGKLal5Am zLbXjt2UGvzk1zDQ{b6dZHN`f0GH%f{X?B5dz?nHFN0FzSF2pXXt0WAj#E!yF1$^Mr zqmf`+fxt!K-vs%OeEI)Dxd^IWu;F0r1adV~76d;%0&Dh85{@hfgB>5^&A#o!T^=l5J9n@xYn<1|^yrsml!Ne-3ol7+ z9HY;8^!4KtU)91t_TIk`UOk(AqP}vcrY7s#a3{OJVJ5kyrNO|-t)ChI=$J*gWw@V5oaJ~r7Or_sR7M(0zd56w)T$aW;@`8r<{FZCe{Cqw zlze}Y7Z4vj7uFN*%AEG5eoYlk&0QO_SRXQ?v5n6Dy3~De1#&LU(BU!myf2`vG)#S4 zm@pa0E^uh^tC-W=k)|yqPrf&*SscDu+~p|M9Uha5AsHRTO*F1-n{+_WT$^bex!pL# z{|$0tv^0HGK{2J{5sibs-1Pj_{*b>w&B*Re1c6m&ts^2Lum$YF=Tf`g%CvYMU3d7Ti(wX7kU!8wqJW`T*VFqzj5ru#pS8SY?%U zr7$K%TYXPIVAO}edyYRa@gOI?%|F#xLBGmWAyyoBhyqgu-BnEPDK;3y5zizq7@$9Azlg z=i1bA)HOV4HH#2NKnH{Ne~kKN^3q>|5Z6fWAC-C-=_IuIU{~XLecm4R%_(Aq?~i$D48BbU zS}`RG%kSSHF5OIz0ti)VZP*@@y@O81&O{t&BlAc29(@iDy zXY&OHs-Sj^G~9D^r%BwdhCr42geey2bBK18@h+j)SNU{W>>Ggne8Fd`-Od+8S)mBK zxoL%X*lH1zm^{S$4G;V``pZD0+#98}lPbGl`TMLf@^TarVN3aVYNQ;+R@m~lvf9H+ z=IH~;#EEDr-kA7=s_(5n#{9?EI<@o=nO-Y?Hx7Joi>H#rt`=Q@J{fBEckG!;b4Gbn z|MaiDhCR?Wj^2s~&yV|h->4+NR+~A}i@K&vV?(CU!358^JJ|XPD`Cs)h*+0Gfl~9) zL3Me9;V)||0S8M0yl89}Qz#WCo0!!soG0@|j37r-imY0U+5X%Z&!7nW^b4TCoHps8}MDB1EpzTAwfZPCL)JtjlrHa6C}uXN4L(9Trcu;xP0{ibHTV zPmPOK!GiE8`PBok*BYE??%U+xa1YBdt4rJ4{r!;o037zV5paVTj<=7=lpz zVUTnIbbWimND?tS#8==Hq!|cbT7G}Um2{{shjqT4C<_rjEssSHtw@g9uX_rO#0FvE zjLUOfNNll%Oe$1_DJyI4snb+7g^G&K+l0QHe$0tp*F(pVlQH1Psfb&uq$vj^#MtZg z{>tsHYz-L3pI$bAOs{(riqj^XJwFF>z$7iLs%F1QPboPY8gj9ct^Ml?zwLBOTjrZPFZU L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs index df660fde..762e02b1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -9,12 +9,12 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class AccessContextFactory : IAccessContextFactory { - private readonly IUserRoleStore _roleStore; + private readonly IUserRoleStoreFactory _userRoleFactory; private readonly IUserIdConverterResolver _converterResolver; - public AccessContextFactory(IUserRoleStore roleStore, IUserIdConverterResolver converterResolver) + public AccessContextFactory(IUserRoleStoreFactory userRoleFactory, IUserIdConverterResolver converterResolver) { - _roleStore = roleStore; + _userRoleFactory = userRoleFactory; _converterResolver = converterResolver; } @@ -45,7 +45,8 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, if (authFlow.IsAuthenticated && authFlow.UserKey is not null) { - var assignments = await _roleStore.GetAssignmentsAsync(authFlow.Tenant, authFlow.UserKey.Value, ct); + 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; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs index 5c531ec4..59de49b3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -1,6 +1,5 @@ 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 CodeBeam.UltimateAuth.Server.Options; @@ -10,12 +9,12 @@ namespace CodeBeam.UltimateAuth.Server.Security; internal sealed class AuthenticationSecurityManager : IAuthenticationSecurityManager { - private readonly IAuthenticationSecurityStateStore _store; + private readonly IAuthenticationSecurityStateStoreFactory _storeFactory; private readonly UAuthServerOptions _options; - public AuthenticationSecurityManager(IAuthenticationSecurityStateStore store, IOptions options) + public AuthenticationSecurityManager(IAuthenticationSecurityStateStoreFactory storeFactory, IOptions options) { - _store = store; + _storeFactory = storeFactory; _options = options.Value; } @@ -23,13 +22,14 @@ public async Task GetOrCreateAccountAsync(TenantKey { ct.ThrowIfCancellationRequested(); - var state = await _store.GetAsync(tenant, userKey, AuthenticationSecurityScope.Account, credentialType: null, ct); + 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); + await store.AddAsync(created, ct); return created; } @@ -37,25 +37,28 @@ public async Task GetOrCreateFactorAsync(TenantKey { ct.ThrowIfCancellationRequested(); - var state = await _store.GetAsync(tenant, userKey, AuthenticationSecurityScope.Factor, type, ct); + 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); + await store.AddAsync(created, ct); return created; } public Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default) { - return _store.UpdateAsync(updated, expectedVersion, ct); + 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(); - return _store.DeleteAsync(tenant, userKey, scope, credentialType, ct); + var store = _storeFactory.Create(tenant); + return store.DeleteAsync(userKey, scope, credentialType, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index 6f06bab2..8694bcb9 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -2,12 +2,19 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - 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 @@ -19,14 +26,16 @@ - - - + - - + + + + + + diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index fd21d395..4387daee 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index b4646a45..4778a004 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Server.ResourceApi; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -66,6 +67,32 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s return services; } + public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + services.AddUltimateAuth(); + + //AddUsersInternal(services); + //AddCredentialsInternal(services); + //AddAuthorizationInternal(services); + //AddUltimateAuthPolicies(services); + + services.AddOptions() + .Configure(options => + { + configure?.Invoke(options); + }) + .BindConfiguration("UltimateAuth:Server") + .PostConfigure(options => + { + options.Endpoints.Authentication = false; + }); + + services.AddUltimateAuthResourceInternal(); + + return services; + } + private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) { services.AddSingleton(); @@ -215,6 +242,8 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); // ----------------------------- @@ -321,6 +350,49 @@ internal static IServiceCollection AddAuthorizationInternal(IServiceCollection s services.TryAddScoped(typeof(IUserClaimsProvider), typeof(AuthorizationClaimsProvider)); return services; } + + // TODO: This is not true, need to build true pipeline for ResourceApi. + private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceCollection services) + { + services.AddSingleton(); + + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + + services.TryAddSingleton(); + + services.AddHttpContextAccessor(); + services.AddAuthentication(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddSingleton(); + + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + + services.PostConfigureAll(options => + { + options.DefaultAuthenticateScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; + }); + + return services; + } } internal sealed class NullTenantResolver : ITenantIdResolver diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs index 22d91d86..d837ebfa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; public static class UAuthRazorExtensions { - public static RazorComponentsEndpointConventionBuilder AddUltimateAuthClientRoutes(this RazorComponentsEndpointConventionBuilder builder,Assembly clientAssembly) + public static RazorComponentsEndpointConventionBuilder AddUltimateAuthRoutes(this RazorComponentsEndpointConventionBuilder builder, Assembly[] clientAssembly) { return builder.AddAdditionalAssemblies(clientAssembly); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index e3b1ba17..c34d264b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -119,12 +119,12 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } // TODO: Add create-time uniqueness guard for chain id for concurrency - var kernel = _storeFactory.Create(request.Tenant); + var sessionStore = _storeFactory.Create(request.Tenant); SessionChainId? chainId = null; if (userKey is not null) { - var chain = await kernel.GetChainByDeviceAsync(request.Tenant, userKey.Value, deviceId, ct); + var chain = await sessionStore.GetChainByDeviceAsync(userKey.Value, deviceId, ct); if (chain is not null && !chain.IsRevoked) chainId = chain.ChainId; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs index 2bacd92e..17f95cb2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs @@ -18,5 +18,4 @@ public async Task ResolveAsync(HttpContext context) var accessor = _services.GetRequiredService>(); await accessor.ResolveAsync(context); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs index fdbc5c37..b3441fbe 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users; namespace CodeBeam.UltimateAuth.Server.Infrastructure; 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/AllowAllAccessPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs new file mode 100644 index 00000000..b6f55185 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi +{ + internal sealed class AllowAllAccessPolicyProvider : IAccessPolicyProvider + { + public IReadOnlyCollection GetPolicies(AccessContext context) + { + throw new NotSupportedException(); + } + + public Task ResolveAsync(string name, CancellationToken ct = default) + => Task.FromResult(new AllowAllPolicy()); + } + + internal sealed class AllowAllPolicy : IAccessPolicy + { + public bool AppliesTo(AccessContext context) + { + throw new NotImplementedException(); + } + + public AccessDecision Decide(AccessContext context) + { + throw new NotImplementedException(); + } + + public Task EvaluateAsync(AccessContext context, CancellationToken ct = default) + => Task.FromResult(true); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs new file mode 100644 index 00000000..de7ff5df --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal class NoOpIdentifierValidator : IIdentifierValidator +{ + public Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default) + { + throw new NotImplementedException(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs new file mode 100644 index 00000000..9b173787 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal sealed class NoOpRefreshTokenValidator : IRefreshTokenValidator +{ + public Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default) + => Task.CompletedTask; + + Task IRefreshTokenValidator.ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct) + { + throw new NotImplementedException(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs new file mode 100644 index 00000000..a6b54a4a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal sealed class NoOpSessionValidator : ISessionValidator +{ + public Task ValidateSesAsync(SessionValidationContext context, CancellationToken ct = default) + => Task.CompletedTask; + + public Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) + { + throw new NotSupportedException(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs new file mode 100644 index 00000000..a1f51a9f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal sealed class NoOpTokenHasher : ITokenHasher +{ + public string Hash(string input) => input; + public bool Verify(string input, string hash) => input == hash; +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs new file mode 100644 index 00000000..4d7e0d92 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal sealed class NoOpUserClaimsProvider : IUserClaimsProvider +{ + public Task> GetClaimsAsync(TenantKey tenant, UserKey user, CancellationToken ct = default) + => Task.FromResult>(Array.Empty()); + + Task IUserClaimsProvider.GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + { + return Task.FromResult(ClaimsSnapshot.Empty); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs new file mode 100644 index 00000000..b2f94991 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal class NotSupportedPasswordHasher : IUAuthPasswordHasher +{ + public string Hash(string password) + { + throw new NotSupportedException(); + } + + public bool Verify(string hash, string secret) + { + throw new NotSupportedException(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..1de3a14d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal class NotSupportedRefreshTokenStoreFactory : IRefreshTokenStoreFactory +{ + public IRefreshTokenStore Create(TenantKey tenant) + { + throw new NotSupportedException(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs new file mode 100644 index 00000000..3f5db1ca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal class NotSupportedSessionStoreFactory : ISessionStoreFactory +{ + public ISessionStore Create(TenantKey tenant) + { + throw new NotSupportedException(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs new file mode 100644 index 00000000..33b75cdb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal class NotSupportedUserRoleStoreFactory : IUserRoleStoreFactory +{ + public IUserRoleStore Create(TenantKey tenant) + { + throw new NotSupportedException(); + } +} 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/Stores/AspNetIdentityUserStore.cs b/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs deleted file mode 100644 index 51b261f7..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Stores; - -public sealed class AspNetIdentityUserStore // : IUAuthUserStore -{ - //private readonly UserManager _users; - - //public AspNetIdentityUserStore(UserManager users) - //{ - // _users = users; - //} - - //public async Task?> FindByUsernameAsync( - // string? tenantId, - // string username, - // CancellationToken cancellationToken = default) - //{ - // var user = await _users.FindByNameAsync(username); - // if (user is null) - // return null; - - // var claims = await _users.GetClaimsAsync(user); - - // return new UAuthUserRecord - // { - // UserId = user.Id, - // Username = user.UserName!, - // PasswordHash = user.PasswordHash!, - // Claims = ClaimsSnapshot.From( - // claims.Select(c => (c.Type, c.Value)).ToArray()) - // }; - //} -} diff --git a/src/CodeBeam.UltimateAuth.Server/logo.png b/src/CodeBeam.UltimateAuth.Server/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/AssemblyVisibility.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs rename to src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/AssemblyVisibility.cs 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..c2ff82b3 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs @@ -0,0 +1,78 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; + +internal sealed class UAuthAuthenticationDbContext : DbContext +{ + public DbSet AuthenticationSecurityStates => Set(); + + + public UAuthAuthenticationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder b) + { + ConfigureAuthenticationSecurityState(b); + } + + private 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); + + e.Property(x => x.LockedUntil); + + e.Property(x => x.RequiresReauthentication) + .IsRequired(); + + e.Property(x => x.ResetRequestedAt); + e.Property(x => x.ResetExpiresAt); + e.Property(x => x.ResetConsumedAt); + + 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 }); + + }); + } +} 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..956212fa --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +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) + { + 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..fc9740ad --- /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; + +internal 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..ac247165 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs @@ -0,0 +1,82 @@ +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 +{ + private readonly UAuthAuthenticationDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreAuthenticationSecurityStateStore(UAuthAuthenticationDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + public async Task GetAsync(UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + var entity = await _db.AuthenticationSecurityStates + .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); + + _db.AuthenticationSecurityStates.Add(entity); + + await _db.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default) + { + var entity = await _db.AuthenticationSecurityStates + .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 _db.AuthenticationSecurityStates + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.Scope == scope && + x.CredentialType == credentialType, + ct); + + if (entity is null) + return; + + _db.AuthenticationSecurityStates.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..74ab7382 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; + +internal sealed class EfCoreAuthenticationSecurityStateStoreFactory : IAuthenticationSecurityStateStoreFactory +{ + private readonly UAuthAuthenticationDbContext _db; + + public EfCoreAuthenticationSecurityStateStoreFactory(UAuthAuthenticationDbContext 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 0ad38403..1129e77a 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj @@ -2,15 +2,28 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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 index 8c7e25ab..6a191aeb 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs @@ -9,16 +9,30 @@ namespace CodeBeam.UltimateAuth.Authentication.InMemory; internal sealed class InMemoryAuthenticationSecurityStateStore : IAuthenticationSecurityStateStore { + private readonly TenantKey _tenant; + private readonly ConcurrentDictionary _byId = new(); - private readonly ConcurrentDictionary<(TenantKey, UserKey, AuthenticationSecurityScope, CredentialType?), Guid> _index = new(); + private readonly ConcurrentDictionary<(UserKey, AuthenticationSecurityScope, CredentialType?), Guid> _index = new(); - public Task GetAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + public InMemoryAuthenticationSecurityStateStore(TenantContext tenant) + { + _tenant = tenant.Tenant; + } + + public Task GetAsync( + UserKey userKey, + AuthenticationSecurityScope scope, + CredentialType? credentialType, + CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_index.TryGetValue((tenant, userKey, scope, credentialType), out var id) && _byId.TryGetValue(id, out var state)) + var key = (userKey, scope, credentialType); + + if (_index.TryGetValue(key, out var id) && + _byId.TryGetValue(id, out var state)) { - return Task.FromResult(state); + return Task.FromResult(state.Snapshot()); } return Task.FromResult(null); @@ -28,12 +42,17 @@ public Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = d { ct.ThrowIfCancellationRequested(); - var key = (state.Tenant, state.UserKey, state.Scope, state.CredentialType); + 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"); - if (!_byId.TryAdd(state.Id, state)) + var snapshot = state.Snapshot(); + + if (!_byId.TryAdd(state.Id, snapshot)) { _index.TryRemove(key, out _); throw new UAuthConflictException("security_state_add_failed"); @@ -46,7 +65,10 @@ public Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, { ct.ThrowIfCancellationRequested(); - var key = (state.Tenant, state.UserKey, state.Scope, state.CredentialType); + 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"); @@ -57,17 +79,23 @@ public Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, if (current.SecurityVersion != expectedVersion) throw new UAuthConflictException("security_state_version_conflict"); - if (!_byId.TryUpdate(state.Id, state, current)) + var next = state.Snapshot(); + + if (!_byId.TryUpdate(state.Id, next, current)) throw new UAuthConflictException("security_state_update_conflict"); return Task.CompletedTask; } - public Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + public Task DeleteAsync( + UserKey userKey, + AuthenticationSecurityScope scope, + CredentialType? credentialType, + CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var key = (tenant, userKey, scope, credentialType); + var key = (userKey, scope, credentialType); if (!_index.TryRemove(key, out var id)) 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 index ab406e99..cdab2eec 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs @@ -1,14 +1,13 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Authentication.InMemory; +namespace CodeBeam.UltimateAuth.Authentication.InMemory.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthInMemoryAuthenticationSecurity(this IServiceCollection services) + public static IServiceCollection AddUltimateAuthAuthenticationInMemory(this IServiceCollection services) { - services.AddSingleton(); - + 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 zcmc&Xc{tSF_xCdxBiqO_iq{O4lwu2DSb;A%=F=K0+c{+32sv-c&N$~BD)Y9%>?&y> zYGCoVO#?RugJtI6?Oj4Xikq_##eExOu8#z~sJ`qwn_V95fXleF?#0s0`#q&cntE&{ zK55kr=c5yGn?Vnb{ahD{i-&@Oj@29x{I~| z%Rk>{!TK(Pw$CBSFiRt)Rdhn7l#WkGuW&mn%)<*8=ft-4vFy6s+vBM@AfTpm9=#I# z_ir~V$)f(``KpAok7R%?KMxzKZi%>38gID@G!89Uq{gUNdTfWiu4=4PtnU9R1*`2f ze#5fNLm&R(75n_%#6E7_D$h1J3cfn{={y@=kccUx7S|%P)qJDKt1tuL6 z4{HXr+cB&HWu=Ed-YV*f_?bc23iM<58(B&6pwre^i{SPTD6s}D(vHYh!l()6oQ8rA zH~~l!yb{8eeKeO_o;!&bhCNgj6dA%mXet28I{;`w0M3E`jw*fC=H(Le%r#``^w+V; zcGZt_zu)w#nwipjvS9P5LAz2C#zE{~cK0>fr@i`}UKKU<+|%dlL|@dmS1fib_mjvo zPSUj3W%&}QVtWvMQ0kA=*Y0`fTg2R%8tdidv|UC^&AU8(4lFjbWvbgyOc)2KNpsz6 z#gv?I_toDkJS)8W03Qj`pDw>rT^b(J{A50P>rQuhP<~LXA%cpSbH}4sT;9|UI$)$= z80@c)g@UQ#prNI|G!$T%hj?@2=Cn1?tKO@RNC60YyVW04!T7#IeTXke05$NJuLqac zX!UCBgJCf6I+C4I0{0J&{InU;Z{PjW^jo`q<|Q*!Q&%+H*yuJ0Iq zKYMY#-@)kDa{7^VpP(JTh@|@9gK@j5mTR8}e3vazM3n!<;WPKu-oT>QxYK!=-F_Lj zJ4(8zJ1+6&%kP)&4NBP15C5S}kvA}5&ojFc3tHa!9dnt_Lc$?}^T(l~EN2u13=aWt zRR&P~gu$TVzbO3sV@#s0xxUQC0mJ!j0bvQBkNrV+tI03%k$r{%5*wBk-W}<%css!J z5=UmK%qCxRCCwk7%OXgp#HKC_3jG>v4;oMgV&MG)Jc|=`h-_xdM<=sLci;Y5iV1%8 zFAJr7#X~@mnczomAxvy}!^We$|Ig^yjGauxsh*o5luu#WW;xWtmC=^0zlu%P#*8NC z(kT74pTCZDx>^CnqJ+DB!M8ryM@&04yvb93AJoW;nX30@Y31eaVM*l9X{MdQ*WWqv8l2+O~Bg3TYlN;#aT@JLXy5AopL zF-0i|$>>PdUU4iF@gvK7%b2z&;y>vj1K{3Ns#b^A<0oBp0fi3{8%Ud$&h8k|Uj5!f zh$|c67&6`2xcahv{@C3h8_PsnAgpf|aWiI8C?Gqa53>=Im#-IV^Bq~qwF$Li&vpQ{ zH@+tQGbzY>wZFDy*#Vpm0_rp{4??0LWcq->E_cP6NgVbzlN_xX(;KgGT@9NMiKysV?5pKJn0Hs--I6f{nOg!sU_}alo$0^APDUCoHQyaD0F2$uKGKLal5Am zLbXjt2UGvzk1zDQ{b6dZHN`f0GH%f{X?B5dz?nHFN0FzSF2pXXt0WAj#E!yF1$^Mr zqmf`+fxt!K-vs%OeEI)Dxd^IWu;F0r1adV~76d;%0&Dh85{@hfgB>5^&A#o!T^=l5J9n@xYn<1|^yrsml!Ne-3ol7+ z9HY;8^!4KtU)91t_TIk`UOk(AqP}vcrY7s#a3{OJVJ5kyrNO|-t)ChI=$J*gWw@V5oaJ~r7Or_sR7M(0zd56w)T$aW;@`8r<{FZCe{Cqw zlze}Y7Z4vj7uFN*%AEG5eoYlk&0QO_SRXQ?v5n6Dy3~De1#&LU(BU!myf2`vG)#S4 zm@pa0E^uh^tC-W=k)|yqPrf&*SscDu+~p|M9Uha5AsHRTO*F1-n{+_WT$^bex!pL# z{|$0tv^0HGK{2J{5sibs-1Pj_{*b>w&B*Re1c6m&ts^2Lum$YF=Tf`g%CvYMU3d7Ti(wX7kU!8wqJW`T*VFqzj5ru#pS8SY?%U zr7$K%TYXPIVAO}edyYRa@gOI?%|F#xLBGmWAyyoBhyqgu-BnEPDK;3y5zizq7@$9Azlg z=i1bA)HOV4HH#2NKnH{Ne~kKN^3q>|5Z6fWAC-C-=_IuIU{~XLecm4R%_(Aq?~i$D48BbU zS}`RG%kSSHF5OIz0ti)VZP*@@y@O81&O{t&BlAc29(@iDy zXY&OHs-Sj^G~9D^r%BwdhCr42geey2bBK18@h+j)SNU{W>>Ggne8Fd`-Od+8S)mBK zxoL%X*lH1zm^{S$4G;V``pZD0+#98}lPbGl`TMLf@^TarVN3aVYNQ;+R@m~lvf9H+ z=IH~;#EEDr-kA7=s_(5n#{9?EI<@o=nO-Y?Hx7Joi>H#rt`=Q@J{fBEckG!;b4Gbn z|MaiDhCR?Wj^2s~&yV|h->4+NR+~A}i@K&vV?(CU!358^JJ|XPD`Cs)h*+0Gfl~9) zL3Me9;V)||0S8M0yl89}Qz#WCo0!!soG0@|j37r-imY0U+5X%Z&!7nW^b4TCoHps8}MDB1EpzTAwfZPCL)JtjlrHa6C}uXN4L(9Trcu;xP0{ibHTV zPmPOK!GiE8`PBok*BYE??%U+xa1YBdt4rJ4{r!;o037zV5paVTj<=7=lpz zVUTnIbbWimND?tS#8==Hq!|cbT7G}Um2{{shjqT4C<_rjEssSHtw@g9uX_rO#0FvE zjLUOfNNll%Oe$1_DJyI4snb+7g^G&K+l0QHe$0tp*F(pVlQH1Psfb&uq$vj^#MtZg z{>tsHYz-L3pI$bAOs{(riqj^XJwFF>z$7iLs%F1QPboPY8gj9ct^Ml?zwLBOTjrZPFZU L597+M377u|47W$d literal 0 HcmV?d00001 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 index ce41f1eb..0d5428ad 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj @@ -2,16 +2,28 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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/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/logo.png b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 8b6785ef..ecb06423 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.csproj @@ -2,17 +2,29 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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 index b2f9780c..7783f457 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs @@ -11,12 +11,9 @@ internal sealed class UAuthAuthorizationDbContext : DbContext public DbSet RolePermissions => Set(); public DbSet UserRoles => Set(); - private readonly TenantContext _tenant; - - public UAuthAuthorizationDbContext(DbContextOptions options, TenantContext tenant) + public UAuthAuthorizationDbContext(DbContextOptions options) : base(options) { - _tenant = tenant; } protected override void OnModelCreating(ModelBuilder b) @@ -30,6 +27,7 @@ private void ConfigureRole(ModelBuilder b) { b.Entity(e => { + e.ToTable("UAuth_Roles"); e.HasKey(x => x.Id); e.Property(x => x.Version) @@ -57,12 +55,12 @@ private void ConfigureRole(ModelBuilder b) .IsRequired(); e.Property(x => x.CreatedAt) - .IsRequired(); + .HasConversion( + v => v.UtcDateTime, + v => new DateTimeOffset(v, TimeSpan.Zero)); e.HasIndex(x => new { x.Tenant, x.Id }).IsUnique(); e.HasIndex(x => new { x.Tenant, x.NormalizedName }).IsUnique(); - - e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); }); } @@ -70,6 +68,7 @@ private void ConfigureRolePermission(ModelBuilder b) { b.Entity(e => { + e.ToTable("UAuth_RolePermissions"); e.HasKey(x => new { x.Tenant, x.RoleId, x.Permission }); e.Property(x => x.Tenant) @@ -91,8 +90,6 @@ private void ConfigureRolePermission(ModelBuilder b) e.HasIndex(x => new { x.Tenant, x.RoleId }); e.HasIndex(x => new { x.Tenant, x.Permission }); - - e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); }); } @@ -100,6 +97,7 @@ private void ConfigureUserRole(ModelBuilder b) { b.Entity(e => { + e.ToTable("UAuth_UserRoles"); e.HasKey(x => new { x.Tenant, x.UserKey, x.RoleId }); e.Property(x => x.Tenant) @@ -127,8 +125,6 @@ private void ConfigureUserRole(ModelBuilder b) e.HasIndex(x => new { x.Tenant, x.UserKey }); e.HasIndex(x => new { x.Tenant, x.RoleId }); - - e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); }); } } \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 8a294a7b..b4554cd4 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -5,11 +5,11 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthEntityFrameworkCoreAuthorization(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthAuthorizationEntityFrameworkCore(this IServiceCollection services, Action configureDb) { - services.AddDbContextPool(configureDb); - services.AddScoped(); - services.AddScoped(); + services.AddDbContext(configureDb); + services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs index b4805a6a..9c640976 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; 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 index 4af2dcac..8c58de33 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -10,17 +9,19 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; internal sealed class EfCoreRoleStore : IRoleStore { private readonly UAuthAuthorizationDbContext _db; + private readonly TenantKey _tenant; - public EfCoreRoleStore(UAuthAuthorizationDbContext db) + public EfCoreRoleStore(UAuthAuthorizationDbContext db, TenantContext tenant) { _db = db; + _tenant = tenant.Tenant; } public async Task ExistsAsync(RoleKey key, CancellationToken ct = default) { return await _db.Roles .AnyAsync(x => - x.Tenant == key.Tenant && + x.Tenant == _tenant && x.Id == key.RoleId, ct); } @@ -29,7 +30,7 @@ public async Task AddAsync(Role role, CancellationToken ct = default) { var exists = await _db.Roles .AnyAsync(x => - x.Tenant == role.Tenant && + x.Tenant == _tenant && x.NormalizedName == role.NormalizedName && x.DeletedAt == null, ct); @@ -42,7 +43,7 @@ public async Task AddAsync(Role role, CancellationToken ct = default) _db.Roles.Add(entity); var permissionEntities = role.Permissions - .Select(p => RolePermissionMapper.ToProjection(role.Tenant, role.Id, p)); + .Select(p => RolePermissionMapper.ToProjection(_tenant, role.Id, p)); _db.RolePermissions.AddRange(permissionEntities); @@ -54,28 +55,28 @@ public async Task AddAsync(Role role, CancellationToken ct = default) var entity = await _db.Roles .AsNoTracking() .SingleOrDefaultAsync(x => - x.Tenant == key.Tenant && + x.Tenant == _tenant && x.Id == key.RoleId, ct); if (entity is null) return null; - var permissionEntities = await _db.RolePermissions + var permissions = await _db.RolePermissions .AsNoTracking() .Where(x => - x.Tenant == key.Tenant && + x.Tenant == _tenant && x.RoleId == key.RoleId) .ToListAsync(ct); - return RoleMapper.ToDomain(entity, permissionEntities); + return RoleMapper.ToDomain(entity, permissions); } public async Task SaveAsync(Role role, long expectedVersion, CancellationToken ct = default) { var entity = await _db.Roles .SingleOrDefaultAsync(x => - x.Tenant == role.Tenant && + x.Tenant == _tenant && x.Id == role.Id, ct); @@ -89,7 +90,7 @@ public async Task SaveAsync(Role role, long expectedVersion, CancellationToken c { var exists = await _db.Roles .AnyAsync(x => - x.Tenant == role.Tenant && + x.Tenant == _tenant && x.NormalizedName == role.NormalizedName && x.Id != role.Id && x.DeletedAt == null, @@ -100,18 +101,21 @@ public async Task SaveAsync(Role role, long expectedVersion, CancellationToken c } RoleMapper.UpdateProjection(role, entity); - entity.Version++; var existingPermissions = await _db.RolePermissions .Where(x => - x.Tenant == role.Tenant && + x.Tenant == _tenant && x.RoleId == role.Id) .ToListAsync(ct); _db.RolePermissions.RemoveRange(existingPermissions); - var newPermissions = role.Permissions.Select(p => RolePermissionMapper.ToProjection(role.Tenant, role.Id, p)); + + var newPermissions = role.Permissions + .Select(p => RolePermissionMapper.ToProjection(_tenant, role.Id, p)); + _db.RolePermissions.AddRange(newPermissions); + await _db.SaveChangesAsync(ct); } @@ -119,7 +123,7 @@ public async Task DeleteAsync(RoleKey key, long expectedVersion, DeleteMode mode { var entity = await _db.Roles .SingleOrDefaultAsync(x => - x.Tenant == key.Tenant && + x.Tenant == _tenant && x.Id == key.RoleId, ct); @@ -131,13 +135,12 @@ public async Task DeleteAsync(RoleKey key, long expectedVersion, DeleteMode mode if (mode == DeleteMode.Hard) { - var permissions = await _db.RolePermissions + await _db.RolePermissions .Where(x => - x.Tenant == key.Tenant && + x.Tenant == _tenant && x.RoleId == key.RoleId) - .ToListAsync(ct); + .ExecuteDeleteAsync(ct); - _db.RolePermissions.RemoveRange(permissions); _db.Roles.Remove(entity); } else @@ -149,12 +152,12 @@ public async Task DeleteAsync(RoleKey key, long expectedVersion, DeleteMode mode await _db.SaveChangesAsync(ct); } - public async Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default) + public async Task GetByNameAsync(string normalizedName, CancellationToken ct = default) { var entity = await _db.Roles .AsNoTracking() .SingleOrDefaultAsync(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.NormalizedName == normalizedName && x.DeletedAt == null, ct); @@ -162,25 +165,24 @@ public async Task DeleteAsync(RoleKey key, long expectedVersion, DeleteMode mode if (entity is null) return null; - var permissionEntities = await _db.RolePermissions + var permissions = await _db.RolePermissions .AsNoTracking() .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.RoleId == entity.Id) .ToListAsync(ct); - return RoleMapper.ToDomain(entity, permissionEntities); + return RoleMapper.ToDomain(entity, permissions); } public async Task> GetByIdsAsync( - TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) { var entities = await _db.Roles .AsNoTracking() .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && roleIds.Contains(x.Id)) .ToListAsync(ct); @@ -189,7 +191,7 @@ public async Task> GetByIdsAsync( var permissions = await _db.RolePermissions .AsNoTracking() .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && roleIdsSet.Contains(x.RoleId)) .ToListAsync(ct); @@ -210,7 +212,6 @@ public async Task> GetByIdsAsync( } public async Task> QueryAsync( - TenantKey tenant, RoleQuery query, CancellationToken ct = default) { @@ -218,7 +219,7 @@ public async Task> QueryAsync( var baseQuery = _db.Roles .AsNoTracking() - .Where(x => x.Tenant == tenant); + .Where(x => x.Tenant == _tenant); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => x.DeletedAt == null); @@ -232,24 +233,16 @@ public async Task> QueryAsync( baseQuery = query.SortBy switch { nameof(Role.CreatedAt) => - query.Descending - ? baseQuery.OrderByDescending(x => x.CreatedAt) - : baseQuery.OrderBy(x => x.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), + 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), + 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), + query.Descending ? baseQuery.OrderByDescending(x => x.NormalizedName) : baseQuery.OrderBy(x => x.NormalizedName), _ => baseQuery.OrderBy(x => x.CreatedAt) }; 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..975dc443 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal sealed class EfCoreRoleStoreFactory : IRoleStoreFactory +{ + private readonly UAuthAuthorizationDbContext _db; + + public EfCoreRoleStoreFactory(UAuthAuthorizationDbContext 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 index c9db3992..24353a98 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs @@ -9,29 +9,31 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; internal sealed class EfCoreUserRoleStore : IUserRoleStore { private readonly UAuthAuthorizationDbContext _db; + private readonly TenantKey _tenant; - public EfCoreUserRoleStore(UAuthAuthorizationDbContext db) + public EfCoreUserRoleStore(UAuthAuthorizationDbContext db, TenantContext tenant) { _db = db; + _tenant = tenant.Tenant; } - public async Task> GetAssignmentsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public async Task> GetAssignmentsAsync(UserKey userKey, CancellationToken ct = default) { var entities = await _db.UserRoles .AsNoTracking() .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey) .ToListAsync(ct); return entities.Select(UserRoleMapper.ToDomain).ToList().AsReadOnly(); } - public async Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) + public async Task AssignAsync(UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) { var exists = await _db.UserRoles .AnyAsync(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey && x.RoleId == roleId, ct); @@ -41,7 +43,7 @@ public async Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, var entity = new UserRoleProjection { - Tenant = tenant, + Tenant = _tenant, UserKey = userKey, RoleId = roleId, AssignedAt = assignedAt @@ -51,11 +53,11 @@ public async Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, await _db.SaveChangesAsync(ct); } - public async Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default) + public async Task RemoveAsync(UserKey userKey, RoleId roleId, CancellationToken ct = default) { var entity = await _db.UserRoles .SingleOrDefaultAsync(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey && x.RoleId == roleId, ct); @@ -67,26 +69,20 @@ public async Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, await _db.SaveChangesAsync(ct); } - public async Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) + public async Task RemoveAssignmentsByRoleAsync(RoleId roleId, CancellationToken ct = default) { - var entities = await _db.UserRoles + await _db.UserRoles .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.RoleId == roleId) - .ToListAsync(ct); - - if (entities.Count == 0) - return; - - _db.UserRoles.RemoveRange(entities); - await _db.SaveChangesAsync(ct); + .ExecuteDeleteAsync(ct); } - public async Task CountAssignmentsAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) + public async Task CountAssignmentsAsync(RoleId roleId, CancellationToken ct = default) { return await _db.UserRoles .CountAsync(x => - x.Tenant == tenant && + 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..516b5e9b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal sealed class EfCoreUserRoleStoreFactory : IUserRoleStoreFactory +{ + private readonly UAuthAuthorizationDbContext _db; + + public EfCoreUserRoleStoreFactory(UAuthAuthorizationDbContext 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index e8e7f49f..90ed11f3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj @@ -2,18 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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 index b62ad6b2..3ce68afc 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Authorization.Reference; -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -9,8 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) { - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); // Never try add - seeding is enumerated and all contributors are added. services.AddSingleton(); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs index 5cb76c78..e9e22abf 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; namespace CodeBeam.UltimateAuth.Authorization.InMemory; @@ -11,32 +10,76 @@ internal sealed class InMemoryAuthorizationSeedContributor : ISeedContributor { public int Order => 20; - private readonly IRoleStore _roleStore; - private readonly IUserRoleStore _roles; + private readonly IRoleStoreFactory _roleStoreFactory; + private readonly IUserRoleStoreFactory _userRoleStoreFactory; private readonly IInMemoryUserIdProvider _ids; private readonly IClock _clock; - public InMemoryAuthorizationSeedContributor(IRoleStore roleStore, IUserRoleStore roles, IInMemoryUserIdProvider ids, IClock clock) + public InMemoryAuthorizationSeedContributor( + IRoleStoreFactory roleStoreFactory, + IUserRoleStoreFactory userRoleStoreFactory, + IInMemoryUserIdProvider ids, + IClock clock) { - _roleStore = roleStore; - _roles = roles; + _roleStoreFactory = roleStoreFactory; + _userRoleStoreFactory = userRoleStoreFactory; _ids = ids; _clock = clock; } public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { - var adminRoleId = RoleId.From(Guid.NewGuid()); - var userRoleId = RoleId.From(Guid.NewGuid()); var now = _clock.UtcNow; - await _roleStore.AddAsync(Role.Create(adminRoleId, tenant, "Admin", new HashSet() { Permission.Wildcard }, _clock.UtcNow)); - await _roleStore.AddAsync(Role.Create(userRoleId, tenant, "User", null, _clock.UtcNow)); + + var roleStore = _roleStoreFactory.Create(tenant); + var userRoleStore = _userRoleStoreFactory.Create(tenant); + + var adminRole = await roleStore.GetByNameAsync("ADMIN", ct); + if (adminRole is null) + { + adminRole = Role.Create( + RoleId.From(Guid.Parse("11111111-1111-1111-1111-111111111111")), + tenant, + "Admin", + new HashSet { Permission.Wildcard }, + now); + + await roleStore.AddAsync(adminRole, ct); + } + + var userRole = await roleStore.GetByNameAsync("USER", ct); + if (userRole is null) + { + userRole = Role.Create( + RoleId.From(Guid.Parse("22222222-2222-2222-2222-222222222222")), + tenant, + "User", + null, + now); + + await roleStore.AddAsync(userRole, ct); + } var adminKey = _ids.GetAdminUserId(); - await _roles.AssignAsync(tenant, adminKey, adminRoleId, now, ct); - await _roles.AssignAsync(tenant, adminKey, userRoleId, now, ct); + await AssignIfMissingAsync(userRoleStore, adminKey, adminRole.Id, now, ct); + await AssignIfMissingAsync(userRoleStore, adminKey, userRole.Id, now, ct); var userKey = _ids.GetUserUserId(); - await _roles.AssignAsync(tenant, userKey, userRoleId, now, ct); + await AssignIfMissingAsync(userRoleStore, userKey, userRole.Id, now, ct); + } + + private static async Task AssignIfMissingAsync( + IUserRoleStore userRoleStore, + UserKey userKey, + RoleId roleId, + DateTimeOffset assignedAt, + CancellationToken ct) + { + var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct); + + if (assignments.Any(x => x.RoleId == roleId)) + return; + + await userRoleStore.AssignAsync(userKey, roleId, assignedAt, ct); } } 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 index 81843e70..1c47b32f 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -1,20 +1,22 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; -using CodeBeam.UltimateAuth.Core.Abstractions; 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 : InMemoryVersionedStore, IRoleStore +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 (Values().Any(r => - r.Tenant == entity.Tenant && + if (TenantValues().Any(r => r.NormalizedName == entity.NormalizedName && !r.IsDeleted)) { @@ -26,8 +28,7 @@ protected override void BeforeSave(Role entity, Role current, long expectedVersi { if (entity.NormalizedName != current.NormalizedName) { - if (Values().Any(r => - r.Tenant == entity.Tenant && + if (TenantValues().Any(r => r.NormalizedName == entity.NormalizedName && r.Id != entity.Id && !r.IsDeleted)) @@ -37,44 +38,40 @@ protected override void BeforeSave(Role entity, Role current, long expectedVersi } } - public Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default) + public Task GetByNameAsync(string normalizedName, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var role = Values() + var role = TenantValues() .FirstOrDefault(r => - r.Tenant == tenant && r.NormalizedName == normalizedName && !r.IsDeleted); - return Task.FromResult(role); + return Task.FromResult(role?.Snapshot()); } - public Task> GetByIdsAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) + public Task> GetByIdsAsync(IReadOnlyCollection roleIds, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var result = new List(roleIds.Count); + var set = roleIds.ToHashSet(); - foreach (var id in roleIds) - { - if (TryGet(new RoleKey(tenant, id), out var role) && role is not null) - { - result.Add(role.Snapshot()); - } - } + var result = TenantValues() + .Where(r => set.Contains(r.Id) && !r.IsDeleted) + .Select(r => r.Snapshot()) + .ToList() + .AsReadOnly(); return Task.FromResult>(result); } - public Task> QueryAsync(TenantKey tenant, RoleQuery query, CancellationToken ct = default) + public Task> QueryAsync(RoleQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var normalized = query.Normalize(); - var baseQuery = Values() - .Where(r => r.Tenant == tenant); + var baseQuery = TenantValues().AsQueryable(); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(r => !r.IsDeleted); @@ -113,6 +110,7 @@ public Task> QueryAsync(TenantKey tenant, RoleQuery query, Can var items = baseQuery .Skip((normalized.PageNumber - 1) * normalized.PageSize) .Take(normalized.PageSize) + .Select(x => x.Snapshot()) .ToList() .AsReadOnly(); 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 index 64a4c958..8027360b 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -8,35 +8,41 @@ namespace CodeBeam.UltimateAuth.Authorization.InMemory; internal sealed class InMemoryUserRoleStore : IUserRoleStore { - private readonly ConcurrentDictionary<(TenantKey, UserKey), List> _assignments = new(); + private readonly TenantKey _tenant; + private readonly ConcurrentDictionary> _assignments = new(); - public Task> GetAssignmentsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public InMemoryUserRoleStore(TenantContext tenant) + { + _tenant = tenant.Tenant; + } + + public Task> GetAssignmentsAsync(UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_assignments.TryGetValue((tenant, userKey), out var list)) + if (_assignments.TryGetValue(userKey, out var list)) { lock (list) - return Task.FromResult>(list.ToArray()); + return Task.FromResult>(list.Select(x => x).ToArray()); } return Task.FromResult>(Array.Empty()); } - public Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) + public Task AssignAsync(UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var list = _assignments.GetOrAdd((tenant, userKey), _ => new List()); + var list = _assignments.GetOrAdd(userKey, _ => new List()); lock (list) { if (list.Any(x => x.RoleId == roleId)) - throw new UAuthConflictException("Role is already assigned to the user."); + throw new UAuthConflictException("role_already_assigned"); list.Add(new UserRole { - Tenant = tenant, + Tenant = _tenant, UserKey = userKey, RoleId = roleId, AssignedAt = assignedAt @@ -46,11 +52,11 @@ public Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTi return Task.CompletedTask; } - public Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default) + public Task RemoveAsync(UserKey userKey, RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_assignments.TryGetValue((tenant, userKey), out var list)) + if (_assignments.TryGetValue(userKey, out var list)) { lock (list) { @@ -61,17 +67,12 @@ public Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, Cancel return Task.CompletedTask; } - public Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) + public Task RemoveAssignmentsByRoleAsync(RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - foreach (var kv in _assignments) + foreach (var list in _assignments.Values) { - if (kv.Key.Item1 != tenant) - continue; - - var list = kv.Value; - lock (list) { list.RemoveAll(x => x.RoleId == roleId); @@ -81,19 +82,14 @@ public Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, Cancel return Task.CompletedTask; } - public Task CountAssignmentsAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) + public Task CountAssignmentsAsync(RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var count = 0; - foreach (var kv in _assignments) + foreach (var list in _assignments.Values) { - if (kv.Key.Item1 != tenant) - continue; - - var list = kv.Value; - lock (list) { count += list.Count(x => x.RoleId == roleId); 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 345a98ba..1eff5491 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj @@ -2,18 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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/Infrastructure/RolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs index e30a12a9..9073946d 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs @@ -1,16 +1,15 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class RolePermissionResolver : IRolePermissionResolver { - private readonly IRoleStore _roles; + private readonly IRoleStoreFactory _roleFactory; - public RolePermissionResolver(IRoleStore roles) + public RolePermissionResolver(IRoleStoreFactory roleFactory) { - _roles = roles; + _roleFactory = roleFactory; } public async Task> ResolveAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) @@ -18,7 +17,8 @@ public async Task> ResolveAsync(TenantKey tenant if (roleIds.Count == 0) return Array.Empty(); - var roles = await _roles.GetByIdsAsync(tenant, roleIds, ct); + var roleStore = _roleFactory.Create(tenant); + var roles = await roleStore.GetByIdsAsync(roleIds, ct); var permissions = new HashSet(); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs index b7ab7070..0b804c03 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs @@ -6,18 +6,19 @@ namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class UserPermissionStore : IUserPermissionStore { - private readonly IUserRoleStore _userRoles; + private readonly IUserRoleStoreFactory _userRolesFactory; private readonly IRolePermissionResolver _resolver; - public UserPermissionStore(IUserRoleStore userRoles, IRolePermissionResolver resolver) + public UserPermissionStore(IUserRoleStoreFactory userRolesFactory, IRolePermissionResolver resolver) { - _userRoles = userRoles; + _userRolesFactory = userRolesFactory; _resolver = resolver; } public async Task> GetPermissionsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var assignments = await _userRoles.GetAssignmentsAsync(tenant, userKey, ct); + 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/RoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs index c5746fad..6ffed5e2 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; @@ -10,19 +9,19 @@ namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class RoleService : IRoleService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IRoleStore _roles; - private readonly IUserRoleStore _userRoles; + private readonly IRoleStoreFactory _roleFactory; + private readonly IUserRoleStoreFactory _userRoleFactory; private readonly IClock _clock; public RoleService( IAccessOrchestrator accessOrchestrator, - IRoleStore roles, - IUserRoleStore userRoles, + IRoleStoreFactory roleFactory, + IUserRoleStoreFactory userRoleFactory, IClock clock) { _accessOrchestrator = accessOrchestrator; - _roles = roles; - _userRoles = userRoles; + _roleFactory = roleFactory; + _userRoleFactory = userRoleFactory; _clock = clock; } @@ -33,7 +32,8 @@ public async Task CreateAsync(AccessContext context, string name, IEnumera var cmd = new AccessCommand(async innerCt => { var role = Role.Create(RoleId.New(), context.ResourceTenant, name, permissions, _clock.UtcNow); - await _roles.AddAsync(role, innerCt); + var roleStore = _roleFactory.Create(context.ResourceTenant); + await roleStore.AddAsync(role, innerCt); return role; }); @@ -48,7 +48,8 @@ public async Task RenameAsync(AccessContext context, RoleId roleId, string newNa var cmd = new AccessCommand(async innerCt => { var key = new RoleKey(context.ResourceTenant, roleId); - var role = await _roles.GetAsync(key, innerCt); + 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"); @@ -56,7 +57,7 @@ public async Task RenameAsync(AccessContext context, RoleId roleId, string newNa var expected = role.Version; role.Rename(newName, _clock.UtcNow); - await _roles.SaveAsync(role, expected, innerCt); + await roleStore.SaveAsync(role, expected, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -69,15 +70,16 @@ public async Task DeleteAsync(AccessContext context, RoleId ro var cmd = new AccessCommand(async innerCt => { var key = new RoleKey(context.ResourceTenant, roleId); - - var role = await _roles.GetAsync(key, innerCt); + var roleStore = _roleFactory.Create(context.ResourceTenant); + var role = await roleStore.GetAsync(key, innerCt); if (role is null) throw new UAuthNotFoundException("role_not_found"); - var removed = await _userRoles.CountAssignmentsAsync(context.ResourceTenant, roleId, innerCt); - await _userRoles.RemoveAssignmentsByRoleAsync(context.ResourceTenant, roleId, innerCt); - await _roles.DeleteAsync(key, role.Version, mode, _clock.UtcNow, innerCt); + 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 { @@ -97,8 +99,9 @@ public async Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnu var cmd = new AccessCommand(async innerCt => { + var roleStore = _roleFactory.Create(context.ResourceTenant); var key = new RoleKey(context.ResourceTenant, roleId); - var role = await _roles.GetAsync(key, innerCt); + var role = await roleStore.GetAsync(key, innerCt); if (role is null || role.IsDeleted) throw new UAuthNotFoundException("role_not_found"); @@ -106,7 +109,7 @@ public async Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnu var expected = role.Version; role.SetPermissions(permissions, _clock.UtcNow); - await _roles.SaveAsync(role, expected, innerCt); + await roleStore.SaveAsync(role, expected, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -118,7 +121,8 @@ public async Task> QueryAsync(AccessContext context, RoleQuery var cmd = new AccessCommand>(async innerCt => { - return await _roles.QueryAsync(context.ResourceTenant, query, 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 index 21f50c7b..11b3d28b 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -10,15 +10,15 @@ namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class UserRoleService : IUserRoleService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserRoleStore _userRoles; - private readonly IRoleStore _roles; + private readonly IUserRoleStoreFactory _userRoleFactory; + private readonly IRoleStoreFactory _roleFactory; private readonly IClock _clock; - public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore userRoles, IRoleStore roles, IClock clock) + public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IClock clock) { _accessOrchestrator = accessOrchestrator; - _userRoles = userRoles; - _roles = roles; + _userRoleFactory = userRoleFactory; + _roleFactory = roleFactory; _clock = clock; } @@ -30,13 +30,15 @@ public async Task AssignAsync(AccessContext context, UserKey targetUserKey, stri 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 _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); + var role = await roleStore.GetByNameAsync(normalized, innerCt); if (role is null || role.IsDeleted) throw new UAuthNotFoundException("role_not_found"); - await _userRoles.AssignAsync(context.ResourceTenant, targetUserKey, role.Id, now, innerCt); + await userRoleStore.AssignAsync(targetUserKey, role.Id, now, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -48,13 +50,15 @@ public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, stri 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 _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); + var role = await roleStore.GetByNameAsync(normalized, innerCt); if (role is null) return; - await _userRoles.RemoveAsync(context.ResourceTenant, targetUserKey, role.Id, innerCt); + await userRoleStore.RemoveAsync(targetUserKey, role.Id, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -68,9 +72,11 @@ public async Task> GetRolesAsync(AccessContext context { request = request.Normalize(); - var assignments = await _userRoles.GetAssignmentsAsync(context.ResourceTenant, targetUserKey, innerCt); + 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 _roles.GetByIdsAsync(context.ResourceTenant, roleIds, innerCt); + var roles = await roleStore.GetByIdsAsync(roleIds, innerCt); var roleMap = roles.ToDictionary(x => x.Id); 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs index 119d5002..18c59bf3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs index bc794624..948959f6 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Authorization; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs index 5b263885..c771c3bd 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs @@ -1,14 +1,12 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IRoleStore : IVersionedStore { - Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default); - Task> GetByIdsAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default); - Task> QueryAsync(TenantKey tenant, RoleQuery query, CancellationToken ct = default); + 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 index eb2aabaa..bcf221a8 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs index a87fc1e3..d8918a24 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs @@ -1,14 +1,13 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IUserRoleStore { - Task> GetAssignmentsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default); - Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default); - Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default); - Task CountAssignmentsAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default); + 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 index d1493e50..50c5dc51 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -2,17 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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 index f48a15e9..74995da2 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -1,12 +1,11 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; -public sealed class Role : IVersionedEntity, IEntitySnapshot, ISoftDeletable +public sealed class Role : ITenantEntity, IVersionedEntity, IEntitySnapshot, ISoftDeletable { private readonly HashSet _permissions = new(); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs index f52f8cfb..004ee340 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; -namespace CodeBeam.UltimateAuth.Authorization.Domain; +namespace CodeBeam.UltimateAuth.Authorization; public readonly record struct RoleKey( TenantKey Tenant, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs index cc427cdd..7c50a600 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs @@ -8,22 +8,24 @@ namespace CodeBeam.UltimateAuth.Authorization; public sealed class AuthorizationClaimsProvider : IUserClaimsProvider { - private readonly IUserRoleStore _roles; - private readonly IRoleStore _roleStore; + private readonly IUserRoleStoreFactory _userRoleFactory; + private readonly IRoleStoreFactory _roleFactory; private readonly IUserPermissionStore _permissions; - public AuthorizationClaimsProvider(IUserRoleStore roles, IRoleStore roleStore, IUserPermissionStore permissions) + public AuthorizationClaimsProvider(IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IUserPermissionStore permissions) { - _roles = roles; - _roleStore = roleStore; + _userRoleFactory = userRoleFactory; + _roleFactory = roleFactory; _permissions = permissions; } public async Task GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var assignments = await _roles.GetAssignmentsAsync(tenant, userKey, ct); + 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(tenant, roleIds, ct); + var roles = await roleStore.GetByIdsAsync(roleIds, ct); var perms = await _permissions.GetPermissionsAsync(tenant, userKey, ct); var builder = ClaimsSnapshot.Create(); 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthenticationStateProvider.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthenticationStateProvider.cs index 5339f0f4..2f47278b 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthenticationStateProvider.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Components.Authorization; -namespace CodeBeam.UltimateAuth.Client; +namespace CodeBeam.UltimateAuth.Client.Blazor; internal sealed class UAuthAuthenticationStateProvider : AuthenticationStateProvider { diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj similarity index 64% rename from src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj index b9203488..5a9a7ac4 100644 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj @@ -2,31 +2,37 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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 - - - - + + + + - - - - + + + + - - - - + + + + @@ -42,14 +48,17 @@
- - - - + + + + + + + diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UALoginDispatch.razor similarity index 97% rename from src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UALoginDispatch.razor index d4942ba9..af62ef65 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UALoginDispatch.razor @@ -1,6 +1,6 @@ @page "/__uauth/login-redirect" -@namespace CodeBeam.UltimateAuth.Client +@namespace CodeBeam.UltimateAuth.Client.Blazor @using CodeBeam.UltimateAuth.Core.Defaults @using Microsoft.AspNetCore.WebUtilities @inject NavigationManager Nav 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/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs similarity index 70% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs index cbddf34c..ef31c4b2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Components; +using System.Reflection; -namespace CodeBeam.UltimateAuth.Client; +namespace CodeBeam.UltimateAuth.Client.Blazor; public partial class UAuthApp { @@ -8,7 +9,28 @@ public partial class UAuthApp private bool _coordinatorStarted; [Parameter] - public RenderFragment ChildContent { get; set; } = default!; + 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; @@ -83,6 +105,17 @@ private async void HandleReauthRequired() 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; diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthFlowPageBase.cs similarity index 98% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthFlowPageBase.cs index f4723276..8f805211 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthFlowPageBase.cs @@ -4,7 +4,7 @@ using System.Text; using System.Text.Json; -namespace CodeBeam.UltimateAuth.Client; +namespace CodeBeam.UltimateAuth.Client.Blazor; public abstract class UAuthFlowPageBase : UAuthReactiveComponentBase { diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor similarity index 96% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor index d8d4dddf..75ebab7b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor @@ -1,5 +1,5 @@ @* TODO: Optional double-submit prevention for native form submit *@ -@namespace CodeBeam.UltimateAuth.Client +@namespace CodeBeam.UltimateAuth.Client.Blazor @using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Options diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs similarity index 99% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs index cd7de6dc..bb730360 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.JSInterop; -namespace CodeBeam.UltimateAuth.Client; +namespace CodeBeam.UltimateAuth.Client.Blazor; public partial class UAuthLoginForm { diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthReactiveComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthReactiveComponentBase.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthReactiveComponentBase.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthReactiveComponentBase.cs index aed94959..fb67afe8 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthReactiveComponentBase.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthReactiveComponentBase.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Client; +namespace CodeBeam.UltimateAuth.Client.Blazor; public abstract class UAuthReactiveComponentBase : ComponentBase, IDisposable { diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor similarity index 50% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor index deb5e819..49e15f69 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor @@ -1,4 +1,4 @@ -@namespace CodeBeam.UltimateAuth.Client +@namespace CodeBeam.UltimateAuth.Client.Blazor @inherits UAuthReactiveComponentBase @ChildContent diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs similarity index 66% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs index 86bf75e5..c3c4e2dd 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthScope.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; -namespace CodeBeam.UltimateAuth.Client; +namespace CodeBeam.UltimateAuth.Client.Blazor; public partial class UAuthScope : UAuthReactiveComponentBase { diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor similarity index 92% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor index bbcbab52..fca197bb 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor @@ -1,4 +1,4 @@ -@namespace CodeBeam.UltimateAuth.Client +@namespace CodeBeam.UltimateAuth.Client.Blazor @inherits UAuthReactiveComponentBase @using CodeBeam.UltimateAuth.Core.Domain diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs similarity index 98% rename from src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs index 23dc02e9..753f75e9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Client; +namespace CodeBeam.UltimateAuth.Client.Blazor; public partial class UAuthStateView : UAuthReactiveComponentBase { diff --git a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs similarity index 79% rename from src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs index e2173571..b64b221b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs @@ -1,14 +1,15 @@ using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Client.Infrastructure; -namespace CodeBeam.UltimateAuth.Client.Device; +namespace CodeBeam.UltimateAuth.Client.Blazor.Device; public sealed class BrowserDeviceIdStorage : IDeviceIdStorage { private const string Key = "udid"; - private readonly IBrowserStorage _storage; + private readonly IClientStorage _storage; - public BrowserDeviceIdStorage(IBrowserStorage storage) + public BrowserDeviceIdStorage(IClientStorage storage) { _storage = storage; } 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..1d3b84de --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,58 @@ +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.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/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs similarity index 80% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs index 6c3dff3e..017c43a5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserStorage.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs @@ -1,13 +1,14 @@ using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; using Microsoft.JSInterop; -namespace CodeBeam.UltimateAuth.Client.Infrastructure; +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; -public sealed class BrowserStorage : IBrowserStorage +public sealed class BrowserClientStorage : IClientStorage { private readonly IJSRuntime _js; - public BrowserStorage(IJSRuntime js) + public BrowserClientStorage(IJSRuntime js) { _js = js; } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs similarity index 68% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs index 2fa8642c..31d28d09 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/BrowserUAuthBridge.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs @@ -1,6 +1,7 @@ -using Microsoft.JSInterop; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using Microsoft.JSInterop; -namespace CodeBeam.UltimateAuth.Client.Infrastructure; +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; internal sealed class BrowserUAuthBridge : IBrowserUAuthBridge { diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs index a6de47b9..fb90ef57 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Client.Infrastructure; +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; // TODO: Add multi tab single refresh support internal sealed class SessionCoordinator : ISessionCoordinator diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index 7cb88931..b0a2a020 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -1,12 +1,13 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using Microsoft.Extensions.Options; using Microsoft.JSInterop; using System.Net; // TODO: Add fluent helper API like RequiredOk -namespace CodeBeam.UltimateAuth.Client.Infrastructure; +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; internal sealed class UAuthRequestClient : IUAuthRequestClient { 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/CodeBeam.UltimateAuth.Client/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/TScripts/uauth.js rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js diff --git a/src/CodeBeam.UltimateAuth.Client/_Imports.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/_Imports.razor similarity index 79% rename from src/CodeBeam.UltimateAuth.Client/_Imports.razor rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/_Imports.razor index 34f03595..d2138d49 100644 --- a/src/CodeBeam.UltimateAuth.Client/_Imports.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/_Imports.razor @@ -3,3 +3,4 @@ @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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/wwwroot/uauth.min.js rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js diff --git a/src/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj similarity index 77% rename from src/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj rename to src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj index 39441148..f6abad23 100644 --- a/src/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj @@ -3,8 +3,7 @@ net10.0 Exe - enable - enable + false diff --git a/src/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs rename to src/client/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs similarity index 91% rename from src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs rename to src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs index 27871be7..9f605d26 100644 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/IBrowserStorage.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; -public interface IBrowserStorage +public interface IClientStorage { ValueTask SetAsync(StorageScope scope, string key, string value); ValueTask GetAsync(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/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs rename to src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs 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/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs rename to src/client/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs rename to src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs rename to src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs rename to src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs rename to src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs rename to src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs rename to src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs 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/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs rename to src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs rename to src/client/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs rename to src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs rename to src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs rename to src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs similarity index 98% rename from src/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs rename to src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs index 76c9fba4..f7f1cbba 100644 --- a/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs @@ -4,4 +4,4 @@ public enum UAuthRenderMode { Manual = 0, Reactive = 1 -} \ No newline at end of file +} diff --git a/src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs rename to src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs rename to src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs rename to src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs rename to src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs rename to src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs index 1cf9fb4a..8c1001b1 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Client.Devices; -public sealed class UAuthDeviceIdGenerator : IDeviceIdGenerator +internal sealed class UAuthDeviceIdGenerator : IDeviceIdGenerator { public DeviceId Generate() { diff --git a/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs rename to src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs index 5cadcb31..22cb4d31 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Client; -public sealed class UAuthDeviceIdProvider : IDeviceIdProvider +internal sealed class UAuthDeviceIdProvider : IDeviceIdProvider { private readonly IDeviceIdStorage _storage; private readonly IDeviceIdGenerator _generator; diff --git a/src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs rename to src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs rename to src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs rename to src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs rename to src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs b/src/client/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs rename to src/client/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs b/src/client/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs rename to src/client/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs similarity index 73% rename from src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 19cb5c77..7cc9dac0 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,6 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Authentication; using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Client.Devices; -using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; @@ -10,7 +8,6 @@ using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -30,17 +27,47 @@ namespace CodeBeam.UltimateAuth.Client.Extensions; ///
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.TryAddSingleton(); + services.AddOptions() - // Program.cs configuration (lowest precedence) - .Configure(options => + .Configure((options, marker) => { - configure?.Invoke(options); + if (configure != null) + { + marker.MarkConfigured(); + configure(options); + } }) - // appsettings.json (highest precedence) .BindConfiguration("UltimateAuth:Client"); return services.AddUltimateAuthClientInternal(); @@ -60,7 +87,6 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol { services.AddScoped(); - services.AddOptions(); services.AddSingleton, UAuthClientOptionsValidator>(); services.AddSingleton, UAuthClientEndpointOptionsValidator>(); @@ -74,7 +100,6 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol o.AutoRefresh.Interval ??= TimeSpan.FromMinutes(5); }); - services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -83,24 +108,17 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); - services.AddScoped(); services.TryAddScoped(); - services.AddScoped(); services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); services.AddScoped(); - services.AddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.AddAuthorizationCore(); - return services; } } diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/ClientLoginCapabilities.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/ClientLoginCapabilities.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageAttribute.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs similarity index 95% rename from src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs rename to src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs index d5c03fe1..717c9378 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; -internal static class UAuthUrlBuilder +public static class UAuthUrlBuilder { public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant) { 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/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs rename to src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs 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/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs rename to src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs rename to src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs rename to src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs similarity index 91% rename from src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs index 6e45c78c..ca9ec42b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index a9e68bf4..85d21ab3 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Contracts; +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; @@ -9,7 +10,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; -using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using System.Net; using System.Security.Cryptography; @@ -23,18 +23,18 @@ internal class UAuthFlowClient : IFlowClient private readonly IUAuthRequestClient _post; private readonly IUAuthClientEvents _events; private readonly IDeviceIdProvider _deviceIdProvider; + private readonly IReturnUrlProvider _returnUrlProvider; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; - private readonly NavigationManager _nav; - public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IDeviceIdProvider deviceIdProvider, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) + public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IDeviceIdProvider deviceIdProvider, IReturnUrlProvider returnUrlProvider, IOptions options, UAuthClientDiagnostics diagnostics) { _post = post; _events = events; _deviceIdProvider = deviceIdProvider; + _returnUrlProvider = returnUrlProvider; _options = options.Value; _diagnostics = diagnostics; - _nav = nav; } private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); @@ -208,7 +208,7 @@ public async Task BeginPkceAsync(string? returnUrl = null) ?? pkce.ReturnUrl ?? _options.Login.ReturnUrl ?? _options.DefaultReturnUrl - ?? _nav.Uri; + ?? _returnUrlProvider.GetCurrentUrl(); if (pkce.AutoRedirect) { diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs rename to src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client/logo.png b/src/client/CodeBeam.UltimateAuth.Client/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index ce41f1eb..0885e14c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj @@ -2,16 +2,28 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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/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/logo.png b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 45d9f8fb..9ed75d81 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -2,20 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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 index ed530b36..6e40935a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs @@ -1,7 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; @@ -10,12 +8,9 @@ internal sealed class UAuthCredentialDbContext : DbContext { public DbSet PasswordCredentials => Set(); - private readonly TenantContext _tenant; - - public UAuthCredentialDbContext(DbContextOptions options, TenantContext tenant) + public UAuthCredentialDbContext(DbContextOptions options) : base(options) { - _tenant = tenant; } protected override void OnModelCreating(ModelBuilder b) @@ -27,6 +22,7 @@ private void ConfigurePasswordCredential(ModelBuilder b) { b.Entity(e => { + e.ToTable("UAuth_PasswordCredentials"); e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); @@ -63,8 +59,6 @@ private void ConfigurePasswordCredential(ModelBuilder b) 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 }); - - e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); }); } } \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index e235a2a1..23f8b844 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -2,14 +2,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthEntityFrameworkCoreCredentials(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthCredentialsEntityFrameworkCore(this IServiceCollection services, Action configureDb) { - services.AddDbContextPool(configureDb); - services.AddScoped(); + 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 index 8fbf3854..8d91b84b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs @@ -18,16 +18,20 @@ public static PasswordCredential ToDomain(this PasswordCredentialProjection p) Source = p.Source }; - return PasswordCredential.Create( + return PasswordCredential.FromProjection( id: p.Id, tenant: p.Tenant, userKey: p.UserKey, secretHash: p.SecretHash, security: security, metadata: metadata, - now: p.CreatedAt + createdAt: p.CreatedAt, + updatedAt: p.UpdatedAt, + deletedAt: p.DeletedAt, + version: p.Version ); } + public static PasswordCredentialProjection ToProjection(this PasswordCredential c) { return new PasswordCredentialProjection @@ -63,5 +67,6 @@ public static void UpdateProjection(this PasswordCredential c, PasswordCredentia 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/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 index ae1fe5f0..bf533435 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs @@ -11,12 +11,12 @@ namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; internal sealed class EfCorePasswordCredentialStore : IPasswordCredentialStore { private readonly UAuthCredentialDbContext _db; - private readonly TenantContext _tenant; + private readonly TenantKey _tenant; public EfCorePasswordCredentialStore(UAuthCredentialDbContext db, TenantContext tenant) { _db = db; - _tenant = tenant; + _tenant = tenant.Tenant; } public async Task ExistsAsync(CredentialKey key, CancellationToken ct = default) @@ -24,14 +24,16 @@ public async Task ExistsAsync(CredentialKey key, CancellationToken ct = de return await _db.PasswordCredentials .AnyAsync(x => x.Id == key.Id && - x.Tenant == key.Tenant, + x.Tenant == _tenant, ct); } public async Task AddAsync(PasswordCredential credential, CancellationToken ct = default) { var entity = credential.ToProjection(); + _db.PasswordCredentials.Add(entity); + await _db.SaveChangesAsync(ct); } @@ -41,7 +43,7 @@ public async Task AddAsync(PasswordCredential credential, CancellationToken ct = .AsNoTracking() .SingleOrDefaultAsync( x => x.Id == key.Id && - x.Tenant == key.Tenant, + x.Tenant == _tenant, ct); return entity?.ToDomain(); @@ -52,7 +54,7 @@ public async Task SaveAsync(PasswordCredential credential, long expectedVersion, var entity = await _db.PasswordCredentials .SingleOrDefaultAsync(x => x.Id == credential.Id && - x.Tenant == credential.Tenant, + x.Tenant == _tenant, ct); if (entity is null) @@ -63,18 +65,30 @@ public async Task SaveAsync(PasswordCredential credential, long expectedVersion, credential.UpdateProjection(entity); entity.Version++; + await _db.SaveChangesAsync(ct); } public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) { - var credential = await GetAsync(key, ct); + var entity = await _db.PasswordCredentials + .SingleOrDefaultAsync(x => + x.Id == key.Id && + x.Tenant == _tenant, + ct); - if (credential is null) + if (entity is null) throw new UAuthNotFoundException("credential_not_found"); - var revoked = credential.Revoke(revokedAt); - await SaveAsync(revoked, expectedVersion, ct); + 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) @@ -82,7 +96,7 @@ public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMod var entity = await _db.PasswordCredentials .SingleOrDefaultAsync(x => x.Id == key.Id && - x.Tenant == key.Tenant, + x.Tenant == _tenant, ct); if (entity is null) @@ -97,19 +111,20 @@ public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMod } else { - entity.DeletedAt = now; + var domain = entity.ToDomain().MarkDeleted(now); + domain.UpdateProjection(entity); entity.Version++; } await _db.SaveChangesAsync(ct); } - public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public async Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) { var entities = await _db.PasswordCredentials .AsNoTracking() .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey && x.DeletedAt == null) .ToListAsync(ct); @@ -120,27 +135,28 @@ public async Task> GetByUserAsync(Tenant .AsReadOnly(); } - public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { - var entities = await _db.PasswordCredentials - .Where(x => - x.Tenant == tenant && - x.UserKey == userKey) - .ToListAsync(ct); - - foreach (var entity in entities) + if (mode == DeleteMode.Hard) { - if (mode == DeleteMode.Hard) - { - _db.PasswordCredentials.Remove(entity); - } - else - { - entity.DeletedAt = now; - entity.Version++; - } + await _db.PasswordCredentials + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey) + .ExecuteDeleteAsync(ct); + + return; } - await _db.SaveChangesAsync(ct); + await _db.PasswordCredentials + .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); } -} \ No newline at end of file +} 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..ba037a79 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCorePasswordCredentialStoreFactory : IPasswordCredentialStoreFactory +{ + private readonly UAuthCredentialDbContext _db; + + public EfCorePasswordCredentialStoreFactory(UAuthCredentialDbContext 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 9229218f..40c7c5ff 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj @@ -2,19 +2,31 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index ed3e2ade..84b01dbc 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -1,10 +1,10 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; -using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; namespace CodeBeam.UltimateAuth.Credentials.InMemory; @@ -14,14 +14,14 @@ internal sealed class InMemoryCredentialSeedContributor : ISeedContributor private static readonly Guid _userPasswordId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); public int Order => 10; - private readonly IPasswordCredentialStore _credentials; + private readonly IPasswordCredentialStoreFactory _credentialFactory; private readonly IInMemoryUserIdProvider _ids; private readonly IUAuthPasswordHasher _hasher; private readonly IClock _clock; - public InMemoryCredentialSeedContributor(IPasswordCredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) + public InMemoryCredentialSeedContributor(IPasswordCredentialStoreFactory credentialFactory, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) { - _credentials = credentials; + _credentialFactory = credentialFactory; _ids = ids; _hasher = hasher; _clock = clock; @@ -37,7 +37,8 @@ private async Task SeedCredentialAsync(UserKey userKey, Guid credentialId, strin { try { - await _credentials.AddAsync( + var credentialStore = _credentialFactory.Create(tenant); + await credentialStore.AddAsync( PasswordCredential.Create( credentialId, tenant, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs index 61f22f38..0d1d7981 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs @@ -1,21 +1,25 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +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 : InMemoryVersionedStore, IPasswordCredentialStore +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 = Values() + var exists = TenantValues() .Any(x => x.Tenant == entity.Tenant && x.UserKey == entity.UserKey && @@ -25,13 +29,12 @@ protected override void BeforeAdd(PasswordCredential entity) throw new UAuthConflictException("password_credential_exists"); } - public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var result = Values() + var result = TenantValues() .Where(x => - x.Tenant == tenant && x.UserKey == userKey && !x.IsDeleted) .Select(x => x.Snapshot()) @@ -50,15 +53,15 @@ public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expect return SaveAsync(revoked, expectedVersion, ct); } - public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { - var credentials = Values() - .Where(c => c.Tenant == tenant && c.UserKey == userKey) + var credentials = TenantValues() + .Where(c => c.UserKey == userKey) .ToList(); foreach (var credential in credentials) { - await DeleteAsync(new CredentialKey(tenant, credential.Id), credential.Version, mode, now, ct); + 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 index 86118255..ef75640c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -9,8 +9,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddSingleton(); + services.TryAddSingleton(); // Never try add seed services.AddSingleton(); 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 zcmc&Xc{tSF_xCdxBiqO_iq{O4lwu2DSb;A%=F=K0+c{+32sv-c&N$~BD)Y9%>?&y> zYGCoVO#?RugJtI6?Oj4Xikq_##eExOu8#z~sJ`qwn_V95fXleF?#0s0`#q&cntE&{ zK55kr=c5yGn?Vnb{ahD{i-&@Oj@29x{I~| z%Rk>{!TK(Pw$CBSFiRt)Rdhn7l#WkGuW&mn%)<*8=ft-4vFy6s+vBM@AfTpm9=#I# z_ir~V$)f(``KpAok7R%?KMxzKZi%>38gID@G!89Uq{gUNdTfWiu4=4PtnU9R1*`2f ze#5fNLm&R(75n_%#6E7_D$h1J3cfn{={y@=kccUx7S|%P)qJDKt1tuL6 z4{HXr+cB&HWu=Ed-YV*f_?bc23iM<58(B&6pwre^i{SPTD6s}D(vHYh!l()6oQ8rA zH~~l!yb{8eeKeO_o;!&bhCNgj6dA%mXet28I{;`w0M3E`jw*fC=H(Le%r#``^w+V; zcGZt_zu)w#nwipjvS9P5LAz2C#zE{~cK0>fr@i`}UKKU<+|%dlL|@dmS1fib_mjvo zPSUj3W%&}QVtWvMQ0kA=*Y0`fTg2R%8tdidv|UC^&AU8(4lFjbWvbgyOc)2KNpsz6 z#gv?I_toDkJS)8W03Qj`pDw>rT^b(J{A50P>rQuhP<~LXA%cpSbH}4sT;9|UI$)$= z80@c)g@UQ#prNI|G!$T%hj?@2=Cn1?tKO@RNC60YyVW04!T7#IeTXke05$NJuLqac zX!UCBgJCf6I+C4I0{0J&{InU;Z{PjW^jo`q<|Q*!Q&%+H*yuJ0Iq zKYMY#-@)kDa{7^VpP(JTh@|@9gK@j5mTR8}e3vazM3n!<;WPKu-oT>QxYK!=-F_Lj zJ4(8zJ1+6&%kP)&4NBP15C5S}kvA}5&ojFc3tHa!9dnt_Lc$?}^T(l~EN2u13=aWt zRR&P~gu$TVzbO3sV@#s0xxUQC0mJ!j0bvQBkNrV+tI03%k$r{%5*wBk-W}<%css!J z5=UmK%qCxRCCwk7%OXgp#HKC_3jG>v4;oMgV&MG)Jc|=`h-_xdM<=sLci;Y5iV1%8 zFAJr7#X~@mnczomAxvy}!^We$|Ig^yjGauxsh*o5luu#WW;xWtmC=^0zlu%P#*8NC z(kT74pTCZDx>^CnqJ+DB!M8ryM@&04yvb93AJoW;nX30@Y31eaVM*l9X{MdQ*WWqv8l2+O~Bg3TYlN;#aT@JLXy5AopL zF-0i|$>>PdUU4iF@gvK7%b2z&;y>vj1K{3Ns#b^A<0oBp0fi3{8%Ud$&h8k|Uj5!f zh$|c67&6`2xcahv{@C3h8_PsnAgpf|aWiI8C?Gqa53>=Im#-IV^Bq~qwF$Li&vpQ{ zH@+tQGbzY>wZFDy*#Vpm0_rp{4??0LWcq->E_cP6NgVbzlN_xX(;KgGT@9NMiKysV?5pKJn0Hs--I6f{nOg!sU_}alo$0^APDUCoHQyaD0F2$uKGKLal5Am zLbXjt2UGvzk1zDQ{b6dZHN`f0GH%f{X?B5dz?nHFN0FzSF2pXXt0WAj#E!yF1$^Mr zqmf`+fxt!K-vs%OeEI)Dxd^IWu;F0r1adV~76d;%0&Dh85{@hfgB>5^&A#o!T^=l5J9n@xYn<1|^yrsml!Ne-3ol7+ z9HY;8^!4KtU)91t_TIk`UOk(AqP}vcrY7s#a3{OJVJ5kyrNO|-t)ChI=$J*gWw@V5oaJ~r7Or_sR7M(0zd56w)T$aW;@`8r<{FZCe{Cqw zlze}Y7Z4vj7uFN*%AEG5eoYlk&0QO_SRXQ?v5n6Dy3~De1#&LU(BU!myf2`vG)#S4 zm@pa0E^uh^tC-W=k)|yqPrf&*SscDu+~p|M9Uha5AsHRTO*F1-n{+_WT$^bex!pL# z{|$0tv^0HGK{2J{5sibs-1Pj_{*b>w&B*Re1c6m&ts^2Lum$YF=Tf`g%CvYMU3d7Ti(wX7kU!8wqJW`T*VFqzj5ru#pS8SY?%U zr7$K%TYXPIVAO}edyYRa@gOI?%|F#xLBGmWAyyoBhyqgu-BnEPDK;3y5zizq7@$9Azlg z=i1bA)HOV4HH#2NKnH{Ne~kKN^3q>|5Z6fWAC-C-=_IuIU{~XLecm4R%_(Aq?~i$D48BbU zS}`RG%kSSHF5OIz0ti)VZP*@@y@O81&O{t&BlAc29(@iDy zXY&OHs-Sj^G~9D^r%BwdhCr42geey2bBK18@h+j)SNU{W>>Ggne8Fd`-Od+8S)mBK zxoL%X*lH1zm^{S$4G;V``pZD0+#98}lPbGl`TMLf@^TarVN3aVYNQ;+R@m~lvf9H+ z=IH~;#EEDr-kA7=s_(5n#{9?EI<@o=nO-Y?Hx7Joi>H#rt`=Q@J{fBEckG!;b4Gbn z|MaiDhCR?Wj^2s~&yV|h->4+NR+~A}i@K&vV?(CU!358^JJ|XPD`Cs)h*+0Gfl~9) zL3Me9;V)||0S8M0yl89}Qz#WCo0!!soG0@|j37r-imY0U+5X%Z&!7nW^b4TCoHps8}MDB1EpzTAwfZPCL)JtjlrHa6C}uXN4L(9Trcu;xP0{ibHTV zPmPOK!GiE8`PBok*BYE??%U+xa1YBdt4rJ4{r!;o037zV5paVTj<=7=lpz zVUTnIbbWimND?tS#8==Hq!|cbT7G}Um2{{shjqT4C<_rjEssSHtw@g9uX_rO#0FvE zjLUOfNNll%Oe$1_DJyI4snb+7g^G&K+l0QHe$0tp*F(pVlQH1Psfb&uq$vj^#MtZg z{>tsHYz-L3pI$bAOs{(riqj^XJwFF>z$7iLs%F1QPboPY8gj9ct^Ml?zwLBOTjrZPFZU L597+M377u|47W$d literal 0 HcmV?d00001 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 index 20c61cec..1b75f477 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj @@ -2,19 +2,29 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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 index ee5c9c64..6e24cb6c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class PasswordCredential : ISecretCredential, IVersionedEntity, IEntitySnapshot, ISoftDeletable +public sealed class PasswordCredential : ISecretCredential, ITenantEntity, IVersionedEntity, IEntitySnapshot, ISoftDeletable { public Guid Id { get; init; } public TenantKey Tenant { get; init; } @@ -151,4 +151,29 @@ public PasswordCredential MarkDeleted(DateTimeOffset 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/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index 2ee133b7..b7c8ae8a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -4,18 +4,17 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace CodeBeam.UltimateAuth.Credentials.Reference +namespace CodeBeam.UltimateAuth.Credentials.Reference.Extensions; + +public static class ServiceCollectionExtensions { - public static class ServiceCollectionExtensions + public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) { - public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) - { - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.AddScoped(); - return 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 index cfe691bb..4f5dac13 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs @@ -5,20 +5,21 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class PasswordCredentialProvider : ICredentialProvider { - private readonly IPasswordCredentialStore _store; + private readonly IPasswordCredentialStoreFactory _storeFactory; private readonly ICredentialValidator _validator; public CredentialType Type => CredentialType.Password; - public PasswordCredentialProvider(IPasswordCredentialStore store, ICredentialValidator validator) + public PasswordCredentialProvider(IPasswordCredentialStoreFactory storeFactory, ICredentialValidator validator) { - _store = store; + _storeFactory = storeFactory; _validator = validator; } public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var creds = await _store.GetByUserAsync(tenant, userKey, ct); + var store = _storeFactory.Create(tenant); + var creds = await store.GetByUserAsync(userKey, ct); return creds.Cast().ToList(); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index 039559f7..918ea9c4 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -10,13 +10,13 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegration { - private readonly IPasswordCredentialStore _credentialStore; + private readonly IPasswordCredentialStoreFactory _credentialStoreFactory; private readonly IUAuthPasswordHasher _passwordHasher; private readonly IClock _clock; - public PasswordUserLifecycleIntegration(IPasswordCredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) + public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credentialStoreFactory, IUAuthPasswordHasher passwordHasher, IClock clock) { - _credentialStore = credentialStore; + _credentialStoreFactory = credentialStoreFactory; _passwordHasher = passwordHasher; _clock = clock; } @@ -40,11 +40,13 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r metadata: new CredentialMetadata { }, _clock.UtcNow); - await _credentialStore.AddAsync(credential, ct); + var credentialStore = _credentialStoreFactory.Create(tenant); + await credentialStore.AddAsync(credential, ct); } public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) { - await _credentialStore.DeleteByUserAsync(tenant, userKey, mode, _clock.UtcNow, ct); + var credentialStore = _credentialStoreFactory.Create(tenant); + await credentialStore.DeleteByUserAsync(userKey, mode, _clock.UtcNow, ct); } } 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 index df782936..ad0a4ae0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -8,7 +8,6 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users; -using Microsoft.AspNetCore.Session; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Credentials.Reference; @@ -17,7 +16,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class CredentialManagementService : ICredentialManagementService, IUserCredentialsInternalService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IPasswordCredentialStore _credentials; + private readonly IPasswordCredentialStoreFactory _credentialsFactory; private readonly IAuthenticationSecurityManager _authenticationSecurityManager; private readonly IOpaqueTokenGenerator _tokenGenerator; private readonly INumericCodeGenerator _numericCodeGenerator; @@ -30,7 +29,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService public CredentialManagementService( IAccessOrchestrator accessOrchestrator, - IPasswordCredentialStore credentials, + IPasswordCredentialStoreFactory credentialsFactory, IAuthenticationSecurityManager authenticationSecurityManager, IOpaqueTokenGenerator tokenGenerator, INumericCodeGenerator numericCodeGenerator, @@ -42,7 +41,7 @@ public CredentialManagementService( IClock clock) { _accessOrchestrator = accessOrchestrator; - _credentials = credentials; + _credentialsFactory = credentialsFactory; _authenticationSecurityManager = authenticationSecurityManager; _tokenGenerator = tokenGenerator; _numericCodeGenerator = numericCodeGenerator; @@ -62,8 +61,8 @@ public async Task GetAllAsync(AccessContext context, Cance { var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - - var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); + var store = _credentialsFactory.Create(context.ResourceTenant); + var credentials = await store.GetByUserAsync(subjectUser, innerCt); var dtos = credentials .Select(c => new CredentialInfo @@ -105,7 +104,8 @@ public async Task AddAsync(AccessContext context, AddCreden metadata: new CredentialMetadata(), now: now); - await _credentials.AddAsync(credential, innerCt); + var store = _credentialsFactory.Create(context.ResourceTenant); + await store.AddAsync(credential, innerCt); return AddCredentialResult.Success(credential.Id, credential.Type); }); @@ -122,8 +122,9 @@ public async Task ChangeSecretAsync(AccessContext contex var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); - var pwd = credentials.OfType().Where(c => c.Security.IsUsable(now)).SingleOrDefault(); + 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"); @@ -146,16 +147,16 @@ public async Task ChangeSecretAsync(AccessContext contex var oldVersion = pwd.Version; var newHash = _hasher.Hash(request.NewSecret); var updated = pwd.ChangeSecret(newHash, now); - await _credentials.SaveAsync(updated, oldVersion, innerCt); + await store.SaveAsync(updated, oldVersion, innerCt); var sessionStore = _sessionFactory.Create(context.ResourceTenant); if (context.IsSelfAction && context.ActorChainId is SessionChainId chainId) { - await sessionStore.RevokeOtherChainsAsync(context.ResourceTenant, subjectUser, chainId, now, innerCt); + await sessionStore.RevokeOtherChainsAsync(subjectUser, chainId, now, innerCt); } else { - await sessionStore.RevokeAllChainsAsync(context.ResourceTenant, subjectUser, now, innerCt); + await sessionStore.RevokeAllChainsAsync(subjectUser, now, innerCt); } return ChangeCredentialResult.Success(pwd.Type); @@ -173,7 +174,8 @@ public async Task RevokeAsync(AccessContext context, Rev var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + 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"); @@ -183,7 +185,7 @@ public async Task RevokeAsync(AccessContext context, Rev var oldVersion = pwd.Version; var updated = pwd.Revoke(now); - await _credentials.SaveAsync(updated, oldVersion, innerCt); + await store.SaveAsync(updated, oldVersion, innerCt); return CredentialActionResult.Success(); }); @@ -294,7 +296,8 @@ public async Task CompleteResetAsync(AccessContext conte throw new UAuthConflictException("invalid_reset_token"); } - var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt); + 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) @@ -311,7 +314,7 @@ public async Task CompleteResetAsync(AccessContext conte var newHash = _hasher.Hash(request.NewSecret); var updated = pwd.ChangeSecret(newHash, now); - await _credentials.SaveAsync(updated, oldVersion, innerCt); + await store.SaveAsync(updated, oldVersion, innerCt); return CredentialActionResult.Success(); }); @@ -351,7 +354,8 @@ public async Task DeleteAsync(AccessContext context, Del var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + 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"); @@ -360,7 +364,7 @@ public async Task DeleteAsync(AccessContext context, Del return CredentialActionResult.Fail("credential_not_found"); var oldVersion = pwd.Version; - await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), oldVersion, request.Mode, now, innerCt); + await store.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), oldVersion, request.Mode, now, innerCt); return CredentialActionResult.Success(); }); @@ -375,7 +379,8 @@ async Task IUserCredentialsInternalService.DeleteInterna { ct.ThrowIfCancellationRequested(); - await _credentials.DeleteByUserAsync(tenant, userKey, DeleteMode.Soft, _clock.UtcNow, ct); + 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/IPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStore.cs similarity index 68% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStore.cs index 6bdb1d05..3a044eec 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStore.cs @@ -1,14 +1,13 @@ 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; namespace CodeBeam.UltimateAuth.Credentials.Reference; public interface IPasswordCredentialStore : IVersionedStore { - Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); + 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs deleted file mode 100644 index 36daf785..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ /dev/null @@ -1,18 +0,0 @@ -//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; - -//namespace CodeBeam.UltimateAuth.Credentials; - -//public interface ICredentialStore : IVersionedStore -//{ -// Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - -// Task GetByIdAsync(CredentialKey key, CancellationToken ct = default); - -// Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); - -// Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); -//} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj index 12ee515c..054b6d8d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj @@ -2,17 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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/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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj similarity index 53% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj index 4c065564..f563f619 100644 --- a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj @@ -2,27 +2,41 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonSerializeWrapper.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonSerializeWrapper.cs diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueComparers.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueComparers.cs diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableJsonValueConverter.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableJsonValueConverter.cs diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableSessionChainIdConverter.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableSessionChainIdConverter.cs diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdEfConverter.cs similarity index 100% rename from src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdEfConverter.cs 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/IInMemoryUserIdProvider.cs similarity index 67% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs rename to src/persistence/CodeBeam.UltimateAuth.InMemory/IInMemoryUserIdProvider.cs index 57a25023..a5296452 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/IInMemoryUserIdProvider.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Infrastructure; +namespace CodeBeam.UltimateAuth.InMemory; public interface IInMemoryUserIdProvider { 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/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs rename to src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs index db4d54f6..8d705d32 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs @@ -1,8 +1,9 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using System.Collections.Concurrent; -namespace CodeBeam.UltimateAuth.Core.Abstractions; +namespace CodeBeam.UltimateAuth.InMemory; public abstract class InMemoryVersionedStore : IVersionedStore where TEntity : class, IVersionedEntity, IEntitySnapshot 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj index 6db7b9d7..0078784a 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -2,17 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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/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/logo.png b/src/policies/CodeBeam.UltimateAuth.Policies/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 zcmc&Xc{tSF_xCdxBiqO_iq{O4lwu2DSb;A%=F=K0+c{+32sv-c&N$~BD)Y9%>?&y> zYGCoVO#?RugJtI6?Oj4Xikq_##eExOu8#z~sJ`qwn_V95fXleF?#0s0`#q&cntE&{ zK55kr=c5yGn?Vnb{ahD{i-&@Oj@29x{I~| z%Rk>{!TK(Pw$CBSFiRt)Rdhn7l#WkGuW&mn%)<*8=ft-4vFy6s+vBM@AfTpm9=#I# z_ir~V$)f(``KpAok7R%?KMxzKZi%>38gID@G!89Uq{gUNdTfWiu4=4PtnU9R1*`2f ze#5fNLm&R(75n_%#6E7_D$h1J3cfn{={y@=kccUx7S|%P)qJDKt1tuL6 z4{HXr+cB&HWu=Ed-YV*f_?bc23iM<58(B&6pwre^i{SPTD6s}D(vHYh!l()6oQ8rA zH~~l!yb{8eeKeO_o;!&bhCNgj6dA%mXet28I{;`w0M3E`jw*fC=H(Le%r#``^w+V; zcGZt_zu)w#nwipjvS9P5LAz2C#zE{~cK0>fr@i`}UKKU<+|%dlL|@dmS1fib_mjvo zPSUj3W%&}QVtWvMQ0kA=*Y0`fTg2R%8tdidv|UC^&AU8(4lFjbWvbgyOc)2KNpsz6 z#gv?I_toDkJS)8W03Qj`pDw>rT^b(J{A50P>rQuhP<~LXA%cpSbH}4sT;9|UI$)$= z80@c)g@UQ#prNI|G!$T%hj?@2=Cn1?tKO@RNC60YyVW04!T7#IeTXke05$NJuLqac zX!UCBgJCf6I+C4I0{0J&{InU;Z{PjW^jo`q<|Q*!Q&%+H*yuJ0Iq zKYMY#-@)kDa{7^VpP(JTh@|@9gK@j5mTR8}e3vazM3n!<;WPKu-oT>QxYK!=-F_Lj zJ4(8zJ1+6&%kP)&4NBP15C5S}kvA}5&ojFc3tHa!9dnt_Lc$?}^T(l~EN2u13=aWt zRR&P~gu$TVzbO3sV@#s0xxUQC0mJ!j0bvQBkNrV+tI03%k$r{%5*wBk-W}<%css!J z5=UmK%qCxRCCwk7%OXgp#HKC_3jG>v4;oMgV&MG)Jc|=`h-_xdM<=sLci;Y5iV1%8 zFAJr7#X~@mnczomAxvy}!^We$|Ig^yjGauxsh*o5luu#WW;xWtmC=^0zlu%P#*8NC z(kT74pTCZDx>^CnqJ+DB!M8ryM@&04yvb93AJoW;nX30@Y31eaVM*l9X{MdQ*WWqv8l2+O~Bg3TYlN;#aT@JLXy5AopL zF-0i|$>>PdUU4iF@gvK7%b2z&;y>vj1K{3Ns#b^A<0oBp0fi3{8%Ud$&h8k|Uj5!f zh$|c67&6`2xcahv{@C3h8_PsnAgpf|aWiI8C?Gqa53>=Im#-IV^Bq~qwF$Li&vpQ{ zH@+tQGbzY>wZFDy*#Vpm0_rp{4??0LWcq->E_cP6NgVbzlN_xX(;KgGT@9NMiKysV?5pKJn0Hs--I6f{nOg!sU_}alo$0^APDUCoHQyaD0F2$uKGKLal5Am zLbXjt2UGvzk1zDQ{b6dZHN`f0GH%f{X?B5dz?nHFN0FzSF2pXXt0WAj#E!yF1$^Mr zqmf`+fxt!K-vs%OeEI)Dxd^IWu;F0r1adV~76d;%0&Dh85{@hfgB>5^&A#o!T^=l5J9n@xYn<1|^yrsml!Ne-3ol7+ z9HY;8^!4KtU)91t_TIk`UOk(AqP}vcrY7s#a3{OJVJ5kyrNO|-t)ChI=$J*gWw@V5oaJ~r7Or_sR7M(0zd56w)T$aW;@`8r<{FZCe{Cqw zlze}Y7Z4vj7uFN*%AEG5eoYlk&0QO_SRXQ?v5n6Dy3~De1#&LU(BU!myf2`vG)#S4 zm@pa0E^uh^tC-W=k)|yqPrf&*SscDu+~p|M9Uha5AsHRTO*F1-n{+_WT$^bex!pL# z{|$0tv^0HGK{2J{5sibs-1Pj_{*b>w&B*Re1c6m&ts^2Lum$YF=Tf`g%CvYMU3d7Ti(wX7kU!8wqJW`T*VFqzj5ru#pS8SY?%U zr7$K%TYXPIVAO}edyYRa@gOI?%|F#xLBGmWAyyoBhyqgu-BnEPDK;3y5zizq7@$9Azlg z=i1bA)HOV4HH#2NKnH{Ne~kKN^3q>|5Z6fWAC-C-=_IuIU{~XLecm4R%_(Aq?~i$D48BbU zS}`RG%kSSHF5OIz0ti)VZP*@@y@O81&O{t&BlAc29(@iDy zXY&OHs-Sj^G~9D^r%BwdhCr42geey2bBK18@h+j)SNU{W>>Ggne8Fd`-Od+8S)mBK zxoL%X*lH1zm^{S$4G;V``pZD0+#98}lPbGl`TMLf@^TarVN3aVYNQ;+R@m~lvf9H+ z=IH~;#EEDr-kA7=s_(5n#{9?EI<@o=nO-Y?Hx7Joi>H#rt`=Q@J{fBEckG!;b4Gbn z|MaiDhCR?Wj^2s~&yV|h->4+NR+~A}i@K&vV?(CU!358^JJ|XPD`Cs)h*+0Gfl~9) zL3Me9;V)||0S8M0yl89}Qz#WCo0!!soG0@|j37r-imY0U+5X%Z&!7nW^b4TCoHps8}MDB1EpzTAwfZPCL)JtjlrHa6C}uXN4L(9Trcu;xP0{ibHTV zPmPOK!GiE8`PBok*BYE??%U+xa1YBdt4rJ4{r!;o037zV5paVTj<=7=lpz zVUTnIbbWimND?tS#8==Hq!|cbT7G}Um2{{shjqT4C<_rjEssSHtw@g9uX_rO#0FvE zjLUOfNNll%Oe$1_DJyI4snb+7g^G&K+l0QHe$0tp*F(pVlQH1Psfb&uq$vj^#MtZg z{>tsHYz-L3pI$bAOs{(riqj^XJwFF>z$7iLs%F1QPboPY8gj9ct^Ml?zwLBOTjrZPFZU L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index 3e3e4116..2ec21417 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -3,16 +3,17 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Errors; using Konscious.Security.Cryptography; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Security.Argon2; -public sealed class Argon2PasswordHasher : IUAuthPasswordHasher +internal sealed class Argon2PasswordHasher : IUAuthPasswordHasher { private readonly Argon2Options _options; - public Argon2PasswordHasher(Argon2Options options) + public Argon2PasswordHasher(IOptions options) { - _options = options; + _options = options.Value; } public string Hash(string password) @@ -40,13 +41,20 @@ public bool Verify(string hash, string secret) if (parts.Length != 2) return false; - var salt = Convert.FromBase64String(parts[0]); - var expectedHash = Convert.FromBase64String(parts[1]); + try + { + var salt = Convert.FromBase64String(parts[0]); + var expectedHash = Convert.FromBase64String(parts[1]); - var argon2 = CreateArgon2(secret, salt); - var actualHash = argon2.GetBytes(expectedHash.Length); + var argon2 = CreateArgon2(secret, salt); + var actualHash = argon2.GetBytes(expectedHash.Length); - return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + } + catch + { + return false; + } } private Argon2id CreateArgon2(string password, byte[] salt) 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 index 95281024..3c58caae 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj @@ -2,12 +2,18 @@ net8.0;net9.0;net10.0 - enable - enable - 0.0.1 - 0.0.1 - true $(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 @@ -19,4 +25,9 @@ + + + + + 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 index 2227e6a9..10738291 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs @@ -7,10 +7,15 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthArgon2(this IServiceCollection services, Action? configure = null) { - var options = new Argon2Options(); - configure?.Invoke(options); + if (configure != null) + { + services.Configure(configure); + } + else + { + services.Configure(_ => { }); + } - services.AddSingleton(options); services.AddSingleton(); return services; 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 zcmc&Xc{tSF_xCdxBiqO_iq{O4lwu2DSb;A%=F=K0+c{+32sv-c&N$~BD)Y9%>?&y> zYGCoVO#?RugJtI6?Oj4Xikq_##eExOu8#z~sJ`qwn_V95fXleF?#0s0`#q&cntE&{ zK55kr=c5yGn?Vnb{ahD{i-&@Oj@29x{I~| z%Rk>{!TK(Pw$CBSFiRt)Rdhn7l#WkGuW&mn%)<*8=ft-4vFy6s+vBM@AfTpm9=#I# z_ir~V$)f(``KpAok7R%?KMxzKZi%>38gID@G!89Uq{gUNdTfWiu4=4PtnU9R1*`2f ze#5fNLm&R(75n_%#6E7_D$h1J3cfn{={y@=kccUx7S|%P)qJDKt1tuL6 z4{HXr+cB&HWu=Ed-YV*f_?bc23iM<58(B&6pwre^i{SPTD6s}D(vHYh!l()6oQ8rA zH~~l!yb{8eeKeO_o;!&bhCNgj6dA%mXet28I{;`w0M3E`jw*fC=H(Le%r#``^w+V; zcGZt_zu)w#nwipjvS9P5LAz2C#zE{~cK0>fr@i`}UKKU<+|%dlL|@dmS1fib_mjvo zPSUj3W%&}QVtWvMQ0kA=*Y0`fTg2R%8tdidv|UC^&AU8(4lFjbWvbgyOc)2KNpsz6 z#gv?I_toDkJS)8W03Qj`pDw>rT^b(J{A50P>rQuhP<~LXA%cpSbH}4sT;9|UI$)$= z80@c)g@UQ#prNI|G!$T%hj?@2=Cn1?tKO@RNC60YyVW04!T7#IeTXke05$NJuLqac zX!UCBgJCf6I+C4I0{0J&{InU;Z{PjW^jo`q<|Q*!Q&%+H*yuJ0Iq zKYMY#-@)kDa{7^VpP(JTh@|@9gK@j5mTR8}e3vazM3n!<;WPKu-oT>QxYK!=-F_Lj zJ4(8zJ1+6&%kP)&4NBP15C5S}kvA}5&ojFc3tHa!9dnt_Lc$?}^T(l~EN2u13=aWt zRR&P~gu$TVzbO3sV@#s0xxUQC0mJ!j0bvQBkNrV+tI03%k$r{%5*wBk-W}<%css!J z5=UmK%qCxRCCwk7%OXgp#HKC_3jG>v4;oMgV&MG)Jc|=`h-_xdM<=sLci;Y5iV1%8 zFAJr7#X~@mnczomAxvy}!^We$|Ig^yjGauxsh*o5luu#WW;xWtmC=^0zlu%P#*8NC z(kT74pTCZDx>^CnqJ+DB!M8ryM@&04yvb93AJoW;nX30@Y31eaVM*l9X{MdQ*WWqv8l2+O~Bg3TYlN;#aT@JLXy5AopL zF-0i|$>>PdUU4iF@gvK7%b2z&;y>vj1K{3Ns#b^A<0oBp0fi3{8%Ud$&h8k|Uj5!f zh$|c67&6`2xcahv{@C3h8_PsnAgpf|aWiI8C?Gqa53>=Im#-IV^Bq~qwF$Li&vpQ{ zH@+tQGbzY>wZFDy*#Vpm0_rp{4??0LWcq->E_cP6NgVbzlN_xX(;KgGT@9NMiKysV?5pKJn0Hs--I6f{nOg!sU_}alo$0^APDUCoHQyaD0F2$uKGKLal5Am zLbXjt2UGvzk1zDQ{b6dZHN`f0GH%f{X?B5dz?nHFN0FzSF2pXXt0WAj#E!yF1$^Mr zqmf`+fxt!K-vs%OeEI)Dxd^IWu;F0r1adV~76d;%0&Dh85{@hfgB>5^&A#o!T^=l5J9n@xYn<1|^yrsml!Ne-3ol7+ z9HY;8^!4KtU)91t_TIk`UOk(AqP}vcrY7s#a3{OJVJ5kyrNO|-t)ChI=$J*gWw@V5oaJ~r7Or_sR7M(0zd56w)T$aW;@`8r<{FZCe{Cqw zlze}Y7Z4vj7uFN*%AEG5eoYlk&0QO_SRXQ?v5n6Dy3~De1#&LU(BU!myf2`vG)#S4 zm@pa0E^uh^tC-W=k)|yqPrf&*SscDu+~p|M9Uha5AsHRTO*F1-n{+_WT$^bex!pL# z{|$0tv^0HGK{2J{5sibs-1Pj_{*b>w&B*Re1c6m&ts^2Lum$YF=Tf`g%CvYMU3d7Ti(wX7kU!8wqJW`T*VFqzj5ru#pS8SY?%U zr7$K%TYXPIVAO}edyYRa@gOI?%|F#xLBGmWAyyoBhyqgu-BnEPDK;3y5zizq7@$9Azlg z=i1bA)HOV4HH#2NKnH{Ne~kKN^3q>|5Z6fWAC-C-=_IuIU{~XLecm4R%_(Aq?~i$D48BbU zS}`RG%kSSHF5OIz0ti)VZP*@@y@O81&O{t&BlAc29(@iDy zXY&OHs-Sj^G~9D^r%BwdhCr42geey2bBK18@h+j)SNU{W>>Ggne8Fd`-Od+8S)mBK zxoL%X*lH1zm^{S$4G;V``pZD0+#98}lPbGl`TMLf@^TarVN3aVYNQ;+R@m~lvf9H+ z=IH~;#EEDr-kA7=s_(5n#{9?EI<@o=nO-Y?Hx7Joi>H#rt`=Q@J{fBEckG!;b4Gbn z|MaiDhCR?Wj^2s~&yV|h->4+NR+~A}i@K&vV?(CU!358^JJ|XPD`Cs)h*+0Gfl~9) zL3Me9;V)||0S8M0yl89}Qz#WCo0!!soG0@|j37r-imY0U+5X%Z&!7nW^b4TCoHps8}MDB1EpzTAwfZPCL)JtjlrHa6C}uXN4L(9Trcu;xP0{ibHTV zPmPOK!GiE8`PBok*BYE??%U+xa1YBdt4rJ4{r!;o037zV5paVTj<=7=lpz zVUTnIbbWimND?tS#8==Hq!|cbT7G}Um2{{shjqT4C<_rjEssSHtw@g9uX_rO#0FvE zjLUOfNNll%Oe$1_DJyI4snb+7g^G&K+l0QHe$0tp*F(pVlQH1Psfb&uq$vj^#MtZg z{>tsHYz-L3pI$bAOs{(riqj^XJwFF>z$7iLs%F1QPboPY8gj9ct^Ml?zwLBOTjrZPFZU L597+M377u|47W$d literal 0 HcmV?d00001 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 index 4cda4e65..2f642a6e 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj @@ -2,15 +2,28 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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 index c78491f8..886762d5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -5,38 +5,33 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class UltimateAuthSessionDbContext : DbContext +internal sealed class UAuthSessionDbContext : DbContext { public DbSet Roots => Set(); public DbSet Chains => Set(); public DbSet Sessions => Set(); - private readonly TenantContext _tenant; - - public UltimateAuthSessionDbContext(DbContextOptions options, TenantContext tenant) : base(options) + public UAuthSessionDbContext(DbContextOptions options) : base(options) { - _tenant = tenant; } protected override void OnModelCreating(ModelBuilder b) { - b.Entity() - .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); - - b.Entity() - .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); - - b.Entity() - .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); - b.Entity(e => { + e.ToTable("UAuth_SessionRoots"); e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); - e.Property(x => x.UserKey).IsRequired(); e.Property(x => x.CreatedAt).IsRequired(); + + 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, @@ -60,11 +55,18 @@ protected override void OnModelCreating(ModelBuilder b) b.Entity(e => { + e.ToTable("UAuth_SessionChains"); e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); - e.Property(x => x.UserKey).IsRequired(); e.Property(x => x.CreatedAt).IsRequired(); + + 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, @@ -113,9 +115,17 @@ protected override void OnModelCreating(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).IsRequired(); + + 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, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 82bb21d0..69762efe 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -2,13 +2,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb) + public static IServiceCollection AddUltimateAuthSessionsEntityFrameworkCore(this IServiceCollection services,Action configureDb) { - services.AddDbContextPool(configureDb); + 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 index 271f5c75..5fd8819f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -51,4 +51,18 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) }; } + 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 index 6377ea28..be5f5413 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -42,4 +42,15 @@ public static SessionProjection ToProjection(this UAuthSession s) 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 index d38a02a5..70a2b038 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -34,4 +34,12 @@ public static SessionRootProjection ToProjection(this UAuthSessionRoot root) 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/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 index d52c232f..c9a31e6e 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -9,11 +9,13 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; internal sealed class EfCoreSessionStore : ISessionStore { - private readonly UltimateAuthSessionDbContext _db; + private readonly UAuthSessionDbContext _db; + private readonly TenantKey _tenant; - public EfCoreSessionStore(UltimateAuthSessionDbContext db) + public EfCoreSessionStore(UAuthSessionDbContext db, TenantContext tenant) { _db = db; + _tenant = tenant.Tenant; } public async Task ExecuteAsync(Func action, CancellationToken ct = default) @@ -77,23 +79,32 @@ public async Task ExecuteAsync(Func x.SessionId == sessionId); + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId); return projection?.ToDomain(); } - public Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default) + public async Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var projection = session.ToProjection(); - _db.Sessions.Attach(projection); - _db.Entry(projection).Property(x => x.Version).OriginalValue = expectedVersion; - _db.Entry(projection).State = EntityState.Modified; - return Task.CompletedTask; + var projection = await _db.Sessions + .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 async Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) + public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -103,19 +114,23 @@ public async Task CreateSessionAsync(UAuthSession session, CancellationToken ct throw new InvalidOperationException("New session must have version 0."); _db.Sessions.Add(projection); + + return Task.CompletedTask; } public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId, ct); - if (projection is null || projection.IsRevoked) + if (projection is null || projection.RevokedAt is not null) return false; - var revoked = projection.ToDomain().Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; + return true; } @@ -123,24 +138,35 @@ public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, Cancel { ct.ThrowIfCancellationRequested(); - var chains = await _db.Chains.Where(x => x.UserKey == user).ToListAsync(ct); + var chains = await _db.Chains + .Where(x => x.Tenant == _tenant && x.UserKey == user) + .ToListAsync(ct); + var chainIds = chains.Select(x => x.ChainId).ToList(); - var sessions = await _db.Sessions.Where(x => chainIds.Contains(x.ChainId)).ToListAsync(ct); + + var sessions = await _db.Sessions + .Where(x => x.Tenant == _tenant && chainIds.Contains(x.ChainId)) + .ToListAsync(ct); foreach (var sessionProjection in sessions) { - var session = sessionProjection.ToDomain(); + if (sessionProjection.RevokedAt is not null) + continue; - if (!session.IsRevoked) - _db.Sessions.Update(session.Revoke(at).ToProjection()); + var domain = sessionProjection.ToDomain().Revoke(at); + domain.UpdateProjection(sessionProjection); + sessionProjection.Version++; } foreach (var chainProjection in chains) { - var chain = chainProjection.ToDomain(); + if (chainProjection.ActiveSessionId is null) + continue; - if (chain.ActiveSessionId is not null) - _db.Chains.Update(chain.DetachSession(at).ToProjection()); + var domain = chainProjection.ToDomain().DetachSession(at); + + domain.UpdateProjection(chainProjection); + chainProjection.Version++; } } @@ -148,24 +174,35 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai { ct.ThrowIfCancellationRequested(); - var chains = await _db.Chains.Where(x => x.UserKey == user && x.ChainId != keepChain).ToListAsync(ct); + var chains = await _db.Chains + .Where(x => x.Tenant == _tenant && x.UserKey == user && x.ChainId != keepChain) + .ToListAsync(ct); + var chainIds = chains.Select(x => x.ChainId).ToList(); - var sessions = await _db.Sessions.Where(x => chainIds.Contains(x.ChainId)).ToListAsync(ct); + + var sessions = await _db.Sessions + .Where(x => x.Tenant == _tenant && chainIds.Contains(x.ChainId)) + .ToListAsync(ct); foreach (var sessionProjection in sessions) { - var session = sessionProjection.ToDomain(); + if (sessionProjection.RevokedAt is not null) + continue; - if (!session.IsRevoked) - _db.Sessions.Update(session.Revoke(at).ToProjection()); + var domain = sessionProjection.ToDomain().Revoke(at); + domain.UpdateProjection(sessionProjection); + sessionProjection.Version++; } foreach (var chainProjection in chains) { - var chain = chainProjection.ToDomain(); + if (chainProjection.ActiveSessionId is null) + continue; + + var domain = chainProjection.ToDomain().DetachSession(at); - if (chain.ActiveSessionId is not null) - _db.Chains.Update(chain.DetachSession(at).ToProjection()); + domain.UpdateProjection(chainProjection); + chainProjection.Version++; } } @@ -175,19 +212,19 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai var projection = await _db.Chains .AsNoTracking() - .SingleOrDefaultAsync(x => x.ChainId == chainId); + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId); return projection?.ToDomain(); } - public async Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + public async Task GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var projection = await _db.Chains .AsNoTracking() .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey && x.RevokedAt == null && x.DeviceId == deviceId) @@ -196,22 +233,24 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai return projection?.ToDomain(); } - public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) + public async Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var projection = chain.ToProjection(); - - if (chain.Version != expectedVersion + 1) - throw new InvalidOperationException("Chain version must be incremented by domain."); + var projection = await _db.Chains + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.ChainId == chain.ChainId, + ct); - _db.Entry(projection).State = EntityState.Modified; + if (projection is null) + throw new UAuthNotFoundException("chain_not_found"); - _db.Entry(projection) - .Property(x => x.Version) - .OriginalValue = expectedVersion; + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("chain_concurrency_conflict"); - return Task.CompletedTask; + chain.UpdateProjection(projection); + projection.Version++; } public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) @@ -232,94 +271,86 @@ public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, Ca { ct.ThrowIfCancellationRequested(); - var projection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId); - - if (projection is null) - return; + var projection = await _db.Chains + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); - var chain = projection.ToDomain(); - if (chain.IsRevoked) + if (projection is null || projection.RevokedAt is not null) return; - _db.Chains.Update(chain.Revoke(at).ToProjection()); + 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 _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId, ct); - - if (chainProjection is null) - return; - - var chain = chainProjection.ToDomain(); + var chainProjection = await _db.Chains + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); - if (chain.IsRevoked) + if (chainProjection is null || chainProjection.RevokedAt is not null) return; - var sessions = await _db.Sessions.Where(x => x.ChainId == chainId).ToListAsync(ct); + var sessions = await _db.Sessions + .Where(x => x.Tenant == _tenant && x.ChainId == chainId) + .ToListAsync(ct); foreach (var sessionProjection in sessions) { - var session = sessionProjection.ToDomain(); - - if (session.IsRevoked) + if (sessionProjection.RevokedAt is not null) continue; - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); + var domain = sessionProjection.ToDomain().Revoke(at); + + domain.UpdateProjection(sessionProjection); + sessionProjection.Version++; } - if (chain.ActiveSessionId is not null) + if (chainProjection.ActiveSessionId is not null) { - var updatedChain = chain.DetachSession(at); - _db.Chains.Update(updatedChain.ToProjection()); + var domain = chainProjection.ToDomain().DetachSession(at); + domain.UpdateProjection(chainProjection); + chainProjection.Version++; } } - public async Task RevokeOtherChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId currentChainId, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeOtherChainsAsync(UserKey userKey, SessionChainId currentChainId, DateTimeOffset at, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var projections = await _db.Chains .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey && x.ChainId != currentChainId && - !x.IsRevoked) + x.RevokedAt == null) .ToListAsync(ct); foreach (var projection in projections) { - var chain = projection.ToDomain(); - - if (chain.IsRevoked) - continue; - - _db.Chains.Update(chain.Revoke(at).ToProjection()); + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; } } - public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + public async Task RevokeAllChainsAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var projections = await _db.Chains .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey && - !x.IsRevoked) + x.RevokedAt == null) .ToListAsync(ct); foreach (var projection in projections) { - var chain = projection.ToDomain(); - - if (chain.IsRevoked) - continue; - - _db.Chains.Update(chain.Revoke(at).ToProjection()); + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; } } @@ -329,7 +360,7 @@ public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, DateTi return await _db.Chains .AsNoTracking() - .Where(x => x.ChainId == chainId) + .Where(x => x.Tenant == _tenant && x.ChainId == chainId) .Select(x => x.ActiveSessionId) .SingleOrDefaultAsync(); } @@ -338,39 +369,48 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId { ct.ThrowIfCancellationRequested(); - var projection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId); + var projection = _db.Chains.Local + .FirstOrDefault(x => x.Tenant == _tenant && x.ChainId == chainId); + + if (projection is null) + { + projection = await _db.Chains + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); + } if (projection is null) return; projection.ActiveSessionId = sessionId; - _db.Chains.Update(projection); + projection.Version++; } public async Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.UserKey == userKey, ct); + var rootProjection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); return rootProjection?.ToDomain(); } - public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) + public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var projection = root.ToProjection(); - - if (root.Version != expectedVersion + 1) - throw new InvalidOperationException("Root version must be incremented by domain."); + var projection = await _db.Roots + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == root.UserKey, + ct); - _db.Entry(projection).State = EntityState.Modified; + if (projection is null) + throw new UAuthNotFoundException("root_not_found"); - _db.Entry(projection) - .Property(x => x.Version) - .OriginalValue = expectedVersion; + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("root_concurrency_conflict"); - return Task.CompletedTask; + root.UpdateProjection(projection); + projection.Version++; } public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) @@ -391,13 +431,15 @@ public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, Cancellati { ct.ThrowIfCancellationRequested(); - var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + var projection = await _db.Roots + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); - if (projection is null) + if (projection is null || projection.RevokedAt is not null) return; - var root = projection.ToDomain(); - _db.Roots.Update(root.Revoke(at).ToProjection()); + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; } public async Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default) @@ -406,7 +448,7 @@ public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, Cancellati return await _db.Sessions .AsNoTracking() - .Where(x => x.SessionId == sessionId) + .Where(x => x.Tenant == _tenant && x.SessionId == sessionId) .Select(x => (SessionChainId?)x.ChainId) .SingleOrDefaultAsync(); } @@ -415,11 +457,11 @@ public async Task> GetChainsByUserAsync(UserKey { ct.ThrowIfCancellationRequested(); - var rootsQuery = _db.Roots.AsNoTracking().Where(r => r.UserKey == userKey); + var rootsQuery = _db.Roots.AsNoTracking().Where(x => x.Tenant == _tenant && x.UserKey == userKey); if (!includeHistoricalRoots) { - rootsQuery = rootsQuery.Where(r => !r.IsRevoked); + rootsQuery = rootsQuery.Where(x => x.RevokedAt == null); } var rootIds = await rootsQuery.Select(r => r.RootId).ToListAsync(); @@ -427,7 +469,7 @@ public async Task> GetChainsByUserAsync(UserKey if (rootIds.Count == 0) return Array.Empty(); - var projections = await _db.Chains.AsNoTracking().Where(c => rootIds.Contains(c.RootId)).ToListAsync(); + var projections = await _db.Chains.AsNoTracking().Where(x => x.Tenant == _tenant && rootIds.Contains(x.RootId)).ToListAsync(); return projections.Select(c => c.ToDomain()).ToList(); } @@ -437,7 +479,7 @@ public async Task> GetChainsByRootAsync(Session var projections = await _db.Chains .AsNoTracking() - .Where(x => x.RootId == rootId) + .Where(x => x.Tenant == _tenant && x.RootId == rootId) .ToListAsync(); return projections.Select(x => x.ToDomain()).ToList(); @@ -449,7 +491,7 @@ public async Task> GetSessionsByChainAsync(SessionCh var projections = await _db.Sessions .AsNoTracking() - .Where(x => x.ChainId == chainId) + .Where(x => x.Tenant == _tenant && x.ChainId == chainId) .ToListAsync(); return projections.Select(x => x.ToDomain()).ToList(); @@ -459,7 +501,7 @@ public async Task> GetSessionsByChainAsync(SessionCh { ct.ThrowIfCancellationRequested(); - var projection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.RootId == rootId, ct); + var projection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.RootId == rootId, ct); return projection?.ToDomain(); } @@ -467,7 +509,7 @@ public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken { ct.ThrowIfCancellationRequested(); - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId, ct); if (projection is null) return; @@ -480,27 +522,27 @@ public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset ct.ThrowIfCancellationRequested(); var chainProjection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); if (chainProjection is null) return; - var sessionProjections = await _db.Sessions.Where(x => x.ChainId == chainId && !x.IsRevoked).ToListAsync(); + var sessionProjections = await _db.Sessions + .Where(x => x.Tenant == _tenant && x.ChainId == chainId && x.RevokedAt == null) + .ToListAsync(ct); foreach (var sessionProjection in sessionProjections) { - var session = sessionProjection.ToDomain(); - var revoked = session.Revoke(at); - - _db.Sessions.Update(revoked.ToProjection()); + var revoked = sessionProjection.ToDomain().Revoke(at); + revoked.UpdateProjection(sessionProjection); + sessionProjection.Version++; } - if (!chainProjection.IsRevoked) + if (chainProjection.RevokedAt is null) { - var chain = chainProjection.ToDomain(); - var revokedChain = chain.Revoke(at); - - _db.Chains.Update(revokedChain.ToProjection()); + var revokedChain = chainProjection.ToDomain().Revoke(at); + revokedChain.UpdateProjection(chainProjection); + chainProjection.Version++; } } @@ -508,42 +550,48 @@ public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Can { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + var rootProjection = await _db.Roots + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); if (rootProjection is null) return; - var chainProjections = await _db.Chains.Where(x => x.UserKey == userKey).ToListAsync(); + var chainProjections = await _db.Chains + .Where(x => x.Tenant == _tenant && x.UserKey == userKey) + .ToListAsync(ct); foreach (var chainProjection in chainProjections) { - var chainId = chainProjection.ChainId; - - var sessionProjections = await _db.Sessions.Where(x => x.ChainId == chainId && !x.IsRevoked).ToListAsync(); + var sessions = await _db.Sessions + .Where(x => x.Tenant == _tenant && x.ChainId == chainProjection.ChainId) + .ToListAsync(ct); - foreach (var sessionProjection in sessionProjections) + foreach (var sessionProjection in sessions) { - var session = sessionProjection.ToDomain(); - var revokedSession = session.Revoke(at); + if (sessionProjection.RevokedAt is not null) + continue; + + var sessionDomain = sessionProjection.ToDomain().Revoke(at); - _db.Sessions.Update(revokedSession.ToProjection()); + sessionDomain.UpdateProjection(sessionProjection); + sessionProjection.Version++; } - if (!chainProjection.IsRevoked) + if (chainProjection.RevokedAt is null) { - var chain = chainProjection.ToDomain(); - var revokedChain = chain.Revoke(at); + var chainDomain = chainProjection.ToDomain().Revoke(at); - _db.Chains.Update(revokedChain.ToProjection()); + chainDomain.UpdateProjection(chainProjection); + chainProjection.Version++; } } - if (!rootProjection.IsRevoked) + if (rootProjection.RevokedAt is null) { - var root = rootProjection.ToDomain(); - var revokedRoot = root.Revoke(at); + var rootDomain = rootProjection.ToDomain().Revoke(at); - _db.Roots.Update(revokedRoot.ToProjection()); + 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 index 9200b8ef..b64206f1 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs @@ -1,26 +1,19 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -public sealed class EfCoreSessionStoreFactory : ISessionStoreFactory +internal sealed class EfCoreSessionStoreFactory : ISessionStoreFactory { - private readonly IServiceProvider _sp; + private readonly UAuthSessionDbContext _db; - public EfCoreSessionStoreFactory(IServiceProvider sp) + public EfCoreSessionStoreFactory(UAuthSessionDbContext db) { - _sp = sp; + _db = db; } public ISessionStore Create(TenantKey tenant) { - return ActivatorUtilities.CreateInstance(_sp, new TenantContext(tenant)); + return new EfCoreSessionStore(_db, new TenantContext(tenant)); } - - // TODO: Implement global here - //public ISessionStoreKernel CreateGlobal() - //{ - // return ActivatorUtilities.CreateInstance(_sp, new TenantContext(null, isGlobal: true)); - //} } 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 9351133b..52f19a0c 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj @@ -2,15 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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 index 73fc9452..c21a91d6 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -10,10 +10,16 @@ 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 _roots = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey), UAuthSessionRoot> _roots = new(); public async Task ExecuteAsync(Func action, CancellationToken ct = default) { @@ -222,13 +228,13 @@ public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, Cancella return Task.CompletedTask; } - public Task RevokeOtherChainsAsync(TenantKey tenant, UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + public Task RevokeOtherChainsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); foreach (var (id, chain) in _chains) { - if (chain.Tenant != tenant) + if (chain.Tenant != _tenant) continue; if (chain.UserKey != user) @@ -244,13 +250,13 @@ public Task RevokeOtherChainsAsync(TenantKey tenant, UserKey user, SessionChainI return Task.CompletedTask; } - public Task RevokeAllChainsAsync(TenantKey tenant, UserKey user, DateTimeOffset at, CancellationToken ct = default) + public Task RevokeAllChainsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); foreach (var (id, chain) in _chains) { - if (chain.Tenant != tenant) + if (chain.Tenant != _tenant) continue; if (chain.UserKey != user) @@ -266,7 +272,7 @@ public Task RevokeAllChainsAsync(TenantKey tenant, UserKey user, DateTimeOffset public Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - return Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); + return Task.FromResult(_roots.TryGetValue((_tenant, userKey), out var r) ? r : null); } public Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) @@ -279,13 +285,13 @@ public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Cancellat { ct.ThrowIfCancellationRequested(); - if (!_roots.TryGetValue(root.UserKey, out var current)) + 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[root.UserKey] = root; + _roots[(_tenant, root.UserKey)] = root; return Task.CompletedTask; } @@ -295,13 +301,13 @@ public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = defaul lock (_lock) { - if (_roots.ContainsKey(root.UserKey)) + 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[root.UserKey] = root; + _roots[(_tenant, root.UserKey)] = root; } return Task.CompletedTask; @@ -311,9 +317,9 @@ public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToke { ct.ThrowIfCancellationRequested(); - if (_roots.TryGetValue(userKey, out var root)) + if (_roots.TryGetValue((_tenant, userKey), out var root)) { - _roots[userKey] = root.Revoke(at); + _roots[(_tenant, userKey)] = root.Revoke(at); } return Task.CompletedTask; } @@ -328,7 +334,7 @@ public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToke return Task.FromResult(null); } - public Task> GetChainsByUserAsync(UserKey userKey,bool includeHistoricalRoots = false, CancellationToken ct = default) + public Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -353,13 +359,13 @@ public Task> GetChainsByRootAsync(SessionRootId return Task.FromResult>(result); } - public Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + public Task GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var chain = _chains.Values .FirstOrDefault(c => - c.Tenant == tenant && + c.Tenant == _tenant && c.UserKey == userKey && !c.IsRevoked && c.Device.DeviceId == deviceId); @@ -468,7 +474,7 @@ public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Cancellat lock (_lock) { - if (!_roots.TryGetValue(userKey, out var root)) + if (!_roots.TryGetValue((_tenant, userKey), out var root)) return Task.CompletedTask; var chains = _chains.Values @@ -500,7 +506,7 @@ public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Cancellat if (!root.IsRevoked) { var revokedRoot = root.Revoke(at); - _roots[userKey] = revokedRoot; + _roots[(_tenant, userKey)] = revokedRoot; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs index af6b5e99..b0fdf3c8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs @@ -10,6 +10,6 @@ public sealed class InMemorySessionStoreFactory : ISessionStoreFactory public ISessionStore Create(TenantKey tenant) { - return _kernels.GetOrAdd(tenant, _ => new InMemorySessionStore()); + 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 index 054fcffc..adb0976c 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Sessions.InMemory; +namespace CodeBeam.UltimateAuth.Sessions.InMemory.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) + 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 29b9bf38..18cd4640 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj @@ -2,15 +2,29 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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 index a7caf53b..304b7a74 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs @@ -5,12 +5,12 @@ namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; -internal sealed class UltimateAuthTokenDbContext : DbContext +internal sealed class UAuthTokenDbContext : DbContext { public DbSet RefreshTokens => Set(); - public DbSet RevokedTokenIds => Set(); + //public DbSet RevokedTokenIds => Set(); // TODO: Add when JWT added. - public UltimateAuthTokenDbContext(DbContextOptions options) + public UAuthTokenDbContext(DbContextOptions options) : base(options) { } @@ -19,6 +19,7 @@ protected override void OnModelCreating(ModelBuilder b) { b.Entity(e => { + e.ToTable("UAuth_RefreshTokens"); e.HasKey(x => x.Id); e.Property(x => x.Version) @@ -30,6 +31,12 @@ protected override void OnModelCreating(ModelBuilder b) 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( diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index c3dcc34f..66a01476 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -2,15 +2,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthTokensEntityFrameworkCore(this IServiceCollection services, Action configureDb) { - services.AddDbContextPool(configureDb); + services.AddDbContext(configureDb); services.AddScoped(); - return services; } } 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 index 7347936e..6d4aad70 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -7,13 +7,14 @@ namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore { - private readonly UltimateAuthTokenDbContext _db; + private readonly UAuthTokenDbContext _db; private readonly TenantKey _tenant; + private bool _inTransaction; - public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, TenantKey tenant) + public EfCoreRefreshTokenStore(UAuthTokenDbContext db, TenantContext tenant) { _db = db; - _tenant = tenant; + _tenant = tenant.Tenant; } public async Task ExecuteAsync(Func action, CancellationToken ct = default) @@ -22,8 +23,9 @@ public async Task ExecuteAsync(Func action, Cancellatio await strategy.ExecuteAsync(async () => { - await using var tx = - await _db.Database.BeginTransactionAsync(ct); + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + _inTransaction = true; try { @@ -36,6 +38,10 @@ await strategy.ExecuteAsync(async () => await tx.RollbackAsync(ct); throw; } + finally + { + _inTransaction = false; + } }); } @@ -45,8 +51,9 @@ public async Task ExecuteAsync(Func { - await using var tx = - await _db.Database.BeginTransactionAsync(ct); + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + _inTransaction = true; try { @@ -60,25 +67,36 @@ public async Task ExecuteAsync(Func FindByHashAsync( - string tokenHash, - CancellationToken ct = default) + public async Task FindByHashAsync(string tokenHash, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var p = await _db.RefreshTokens .AsNoTracking() .SingleOrDefaultAsync( @@ -89,12 +107,11 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) return p?.ToDomain(); } - public Task RevokeAsync( - string tokenHash, - DateTimeOffset revokedAt, - string? replacedByTokenHash = null, - CancellationToken ct = default) + public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + var query = _db.RefreshTokens .Where(x => x.Tenant == _tenant && @@ -115,11 +132,11 @@ public Task RevokeAsync( ct); } - public Task RevokeBySessionAsync( - AuthSessionId sessionId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + return _db.RefreshTokens .Where(x => x.Tenant == _tenant && @@ -130,11 +147,11 @@ public Task RevokeBySessionAsync( ct); } - public Task RevokeByChainAsync( - SessionChainId chainId, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + return _db.RefreshTokens .Where(x => x.Tenant == _tenant && @@ -145,11 +162,11 @@ public Task RevokeByChainAsync( ct); } - public Task RevokeAllForUserAsync( - UserKey userKey, - DateTimeOffset revokedAt, - CancellationToken ct = default) + public Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + return _db.RefreshTokens .Where(x => x.Tenant == _tenant && diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs index e329766d..f584beae 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs @@ -1,20 +1,19 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; -public sealed class EfCoreRefreshTokenStoreFactory : IRefreshTokenStoreFactory +internal sealed class EfCoreRefreshTokenStoreFactory : IRefreshTokenStoreFactory { - private readonly IServiceProvider _sp; + private readonly UAuthTokenDbContext _db; - public EfCoreRefreshTokenStoreFactory(IServiceProvider sp) + public EfCoreRefreshTokenStoreFactory(UAuthTokenDbContext db) { - _sp = sp; + _db = db; } public IRefreshTokenStore Create(TenantKey tenant) { - return ActivatorUtilities.CreateInstance(_sp, 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index df84dd42..90b2b5c8 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj @@ -2,14 +2,29 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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 index 683cb61b..a5787f95 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -10,7 +10,7 @@ internal sealed class InMemoryRefreshTokenStore : IRefreshTokenStore private readonly TenantKey _tenant; private readonly SemaphoreSlim _tx = new(1, 1); - private readonly ConcurrentDictionary _tokens = new(); + private readonly ConcurrentDictionary<(TenantKey, string), RefreshToken> _tokens = new(); public InMemoryRefreshTokenStore(TenantKey tenant) { @@ -52,7 +52,7 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) if (token.Tenant != _tenant) throw new InvalidOperationException("Tenant mismatch."); - _tokens[token.TokenHash] = token; + _tokens[(_tenant, token.TokenHash)] = token; return Task.CompletedTask; } @@ -61,7 +61,7 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - _tokens.TryGetValue(tokenHash, out var token); + _tokens.TryGetValue((_tenant, tokenHash), out var token); return Task.FromResult(token); } @@ -70,9 +70,9 @@ public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? repl { ct.ThrowIfCancellationRequested(); - if (_tokens.TryGetValue(tokenHash, out var token) && !token.IsRevoked) + if (_tokens.TryGetValue((_tenant, tokenHash), out var token) && !token.IsRevoked) { - _tokens[tokenHash] = token.Revoke(revokedAt, replacedByTokenHash); + _tokens[(_tenant, tokenHash)] = token.Revoke(revokedAt, replacedByTokenHash); } return Task.CompletedTask; @@ -82,11 +82,14 @@ public Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revoked { ct.ThrowIfCancellationRequested(); - foreach (var (hash, token) in _tokens.ToArray()) + foreach (var ((tenant, hash), token) in _tokens.ToArray()) { + if (tenant != _tenant) + continue; + if (token.SessionId == sessionId && !token.IsRevoked) { - _tokens[hash] = token.Revoke(revokedAt); + _tokens[(_tenant, hash)] = token.Revoke(revokedAt); } } @@ -97,11 +100,14 @@ public Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, { ct.ThrowIfCancellationRequested(); - foreach (var (hash, token) in _tokens.ToArray()) + foreach (var ((tenant, hash), token) in _tokens.ToArray()) { + if (tenant != _tenant) + continue; + if (token.ChainId == chainId && !token.IsRevoked) { - _tokens[hash] = token.Revoke(revokedAt); + _tokens[(_tenant, hash)] = token.Revoke(revokedAt); } } @@ -112,14 +118,17 @@ public Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, Can { ct.ThrowIfCancellationRequested(); - foreach (var (hash, token) in _tokens.ToArray()) + foreach (var ((tenant, hash), token) in _tokens.ToArray()) { + if (tenant != _tenant) + continue; + if (token.UserKey == userKey && !token.IsRevoked) { - _tokens[hash] = token.Revoke(revokedAt); + _tokens[(_tenant, hash)] = token.Revoke(revokedAt); } } return Task.CompletedTask; } -} \ 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 index 76b12711..3b5868d4 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -1,14 +1,13 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Tokens.InMemory; +namespace CodeBeam.UltimateAuth.Tokens.InMemory.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthInMemoryTokens(this IServiceCollection services) + 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 1f3e2def..c21bbf8c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj @@ -2,14 +2,28 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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/IdentifierExistenceQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs index 7906dd6e..e2201471 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs @@ -4,7 +4,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record IdentifierExistenceQuery( - TenantKey Tenant, UserIdentifierType Type, string NormalizedValue, IdentifierExistenceScope Scope, 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/logo.png b/src/users/CodeBeam.UltimateAuth.Users.Contracts/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index 985dcf63..6e53ba0c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj @@ -2,16 +2,29 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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 index 70b601f4..f7f0d05c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs @@ -11,39 +11,23 @@ internal sealed class UAuthUserDbContext : DbContext public DbSet Lifecycles => Set(); public DbSet Profiles => Set(); - private readonly TenantContext _tenant; - - public UAuthUserDbContext(DbContextOptions options, TenantContext tenant) + public UAuthUserDbContext(DbContextOptions options) : base(options) { - _tenant = tenant; } protected override void OnModelCreating(ModelBuilder b) { - ConfigureTenantFilters(b); - ConfigureIdentifiers(b); ConfigureLifecycles(b); ConfigureProfiles(b); } - private void ConfigureTenantFilters(ModelBuilder b) - { - b.Entity() - .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); - - b.Entity() - .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); - - b.Entity() - .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); - } - private void ConfigureIdentifiers(ModelBuilder b) { b.Entity(e => { + e.ToTable("UAuth_UserIdentifiers"); e.HasKey(x => x.Id); e.Property(x => x.Version) @@ -86,6 +70,7 @@ private void ConfigureLifecycles(ModelBuilder b) { b.Entity(e => { + e.ToTable("UAuth_UserLifecycles"); e.HasKey(x => x.Id); e.Property(x => x.Version) @@ -119,6 +104,7 @@ private void ConfigureProfiles(ModelBuilder b) { b.Entity(e => { + e.ToTable("UAuth_UserProfiles"); e.HasKey(x => x.Id); e.Property(x => x.Version) @@ -142,7 +128,7 @@ private void ConfigureProfiles(ModelBuilder b) e.Property(x => x.Metadata) .HasConversion(new NullableJsonValueConverter>()) - .Metadata.SetValueComparer(JsonValueComparers.Create()); + .Metadata.SetValueComparer(JsonValueComparers.Create>()); e.Property(x => x.CreatedAt) .IsRequired(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 5a5ed1ba..656bc78b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -6,12 +6,12 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthEntityFrameworkCoreUsers(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthUsersEntityFrameworkCore(this IServiceCollection services, Action configureDb) { - services.AddDbContextPool(configureDb); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + 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 index 0f7b2e88..75a95e35 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs @@ -39,4 +39,17 @@ public static UserIdentifierProjection ToProjection(this UserIdentifier d) 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 index 654600c3..f7a35eb9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs @@ -33,4 +33,12 @@ public static UserLifecycleProjection ToProjection(this UserLifecycle d) 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 index d18a1bcb..aaa2addb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs @@ -49,4 +49,24 @@ public static UserProfileProjection ToProjection(this UserProfile d) 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/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..8ac0d8c7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserProfileStoreFactory : IUserProfileStoreFactory +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserProfileStoreFactory(UAuthUserDbContext 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 index 86fd189f..5f85b529 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -11,10 +11,12 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; internal sealed class EfCoreUserIdentifierStore : IUserIdentifierStore { private readonly UAuthUserDbContext _db; + private readonly TenantKey _tenant; - public EfCoreUserIdentifierStore(UAuthUserDbContext db) + public EfCoreUserIdentifierStore(UAuthUserDbContext db, TenantContext tenant) { _db = db; + _tenant = tenant.Tenant; } public async Task ExistsAsync(Guid key, CancellationToken ct = default) @@ -22,7 +24,10 @@ public async Task ExistsAsync(Guid key, CancellationToken ct = default) ct.ThrowIfCancellationRequested(); return await _db.Identifiers - .AnyAsync(x => x.Id == key, ct); + .AnyAsync(x => + x.Id == key && + x.Tenant == _tenant, + ct); } public async Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) @@ -32,7 +37,7 @@ public async Task ExistsAsync(IdentifierExistenceQuer var q = _db.Identifiers .AsNoTracking() .Where(x => - x.Tenant == query.Tenant && + x.Tenant == _tenant && x.Type == query.Type && x.NormalizedValue == query.NormalizedValue && x.DeletedAt == null); @@ -79,12 +84,15 @@ public async Task ExistsAsync(IdentifierExistenceQuer var projection = await _db.Identifiers .AsNoTracking() - .SingleOrDefaultAsync(x => x.Id == key, ct); + .SingleOrDefaultAsync(x => + x.Id == key && + x.Tenant == _tenant, + ct); return projection?.ToDomain(); } - public async Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) + public async Task GetAsync(UserIdentifierType type, string normalizedValue, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -92,7 +100,7 @@ public async Task ExistsAsync(IdentifierExistenceQuer .AsNoTracking() .SingleOrDefaultAsync( x => - x.Tenant == tenant && + x.Tenant == _tenant && x.Type == type && x.NormalizedValue == normalizedValue && x.DeletedAt == null, @@ -107,19 +115,22 @@ public async Task ExistsAsync(IdentifierExistenceQuer var projection = await _db.Identifiers .AsNoTracking() - .SingleOrDefaultAsync(x => x.Id == id, ct); + .SingleOrDefaultAsync(x => + x.Id == id && + x.Tenant == _tenant, + ct); return projection?.ToDomain(); } - public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public async Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var projections = await _db.Identifiers .AsNoTracking() .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey && x.DeletedAt == null) .OrderBy(x => x.CreatedAt) @@ -132,18 +143,18 @@ public async Task AddAsync(UserIdentifier entity, CancellationToken ct = default { ct.ThrowIfCancellationRequested(); - var projection = entity.ToProjection(); - 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 _db.Identifiers .Where(x => - x.Tenant == entity.Tenant && + x.Tenant == _tenant && x.UserKey == entity.UserKey && x.Type == entity.Type && x.IsPrimary && @@ -163,15 +174,13 @@ public async Task SaveAsync(UserIdentifier entity, long expectedVersion, Cancell { ct.ThrowIfCancellationRequested(); - var projection = entity.ToProjection(); - using var tx = await _db.Database.BeginTransactionAsync(ct); if (entity.IsPrimary) { await _db.Identifiers .Where(x => - x.Tenant == entity.Tenant && + x.Tenant == _tenant && x.UserKey == entity.UserKey && x.Type == entity.Type && x.Id != entity.Id && @@ -182,88 +191,24 @@ await _db.Identifiers ct); } - _db.Entry(projection).State = EntityState.Modified; + var existing = await _db.Identifiers + .SingleOrDefaultAsync(x => + x.Id == entity.Id && + x.Tenant == _tenant, + ct); - _db.Entry(projection) - .Property(x => x.Version) - .OriginalValue = expectedVersion; + if (existing is null) + throw new UAuthNotFoundException("identifier_not_found"); - try - { - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - } - catch (DbUpdateConcurrencyException) - { + if (existing.Version != expectedVersion) throw new UAuthConcurrencyException("identifier_concurrency_conflict"); - } - } - public async Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + entity.UpdateProjection(existing); - if (query.UserKey is null) - throw new UAuthIdentifierValidationException("userKey_required"); - - var normalized = query.Normalize(); - - var baseQuery = _db.Identifiers - .AsNoTracking() - .Where(x => - x.Tenant == tenant && - x.UserKey == query.UserKey && - (query.IncludeDeleted || 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); - } - - public async Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var projections = await _db.Identifiers - .AsNoTracking() - .Where(x => - x.Tenant == tenant && - userKeys.Contains(x.UserKey) && - x.DeletedAt == null) - .ToListAsync(ct); + existing.Version++; - return projections.Select(x => x.ToDomain()).ToList(); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); } public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) @@ -271,7 +216,10 @@ public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, D ct.ThrowIfCancellationRequested(); var projection = await _db.Identifiers - .SingleOrDefaultAsync(x => x.Id == key, ct); + .SingleOrDefaultAsync(x => + x.Id == key && + x.Tenant == _tenant, + ct); if (projection is null) throw new UAuthNotFoundException("identifier_not_found"); @@ -293,7 +241,7 @@ public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, D await _db.SaveChangesAsync(ct); } - public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -301,7 +249,7 @@ public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMod { await _db.Identifiers .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey) .ExecuteDeleteAsync(ct); @@ -310,7 +258,7 @@ await _db.Identifiers await _db.Identifiers .Where(x => - x.Tenant == tenant && + x.Tenant == _tenant && x.UserKey == userKey && x.DeletedAt == null) .ExecuteUpdateAsync( @@ -319,4 +267,80 @@ await _db.Identifiers .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 _db.Identifiers + .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 = _db.Identifiers + .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..fe6cdbdf --- /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 +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserIdentifierStoreFactory(UAuthUserDbContext 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 index c2b35917..eea3a8c8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -9,10 +9,12 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; internal sealed class EfCoreUserLifecycleStore : IUserLifecycleStore { private readonly UAuthUserDbContext _db; + private readonly TenantKey _tenant; - public EfCoreUserLifecycleStore(UAuthUserDbContext db) + public EfCoreUserLifecycleStore(UAuthUserDbContext db, TenantContext tenant) { _db = db; + _tenant = tenant.Tenant; } public async Task GetAsync(UserLifecycleKey key, CancellationToken ct = default) @@ -22,7 +24,7 @@ public EfCoreUserLifecycleStore(UAuthUserDbContext db) var projection = await _db.Lifecycles .AsNoTracking() .SingleOrDefaultAsync( - x => x.Tenant == key.Tenant && + x => x.Tenant == _tenant && x.UserKey == key.UserKey, ct); @@ -35,7 +37,7 @@ public async Task ExistsAsync(UserLifecycleKey key, CancellationToken ct = return await _db.Lifecycles .AnyAsync( - x => x.Tenant == key.Tenant && + x => x.Tenant == _tenant && x.UserKey == key.UserKey, ct); } @@ -44,11 +46,11 @@ public async Task AddAsync(UserLifecycle entity, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var projection = entity.ToProjection(); - if (entity.Version != 0) throw new InvalidOperationException("New lifecycle must have version 0."); + var projection = entity.ToProjection(); + _db.Lifecycles.Add(projection); await _db.SaveChangesAsync(ct); @@ -58,22 +60,22 @@ public async Task SaveAsync(UserLifecycle entity, long expectedVersion, Cancella { ct.ThrowIfCancellationRequested(); - var projection = entity.ToProjection(); - - _db.Entry(projection).State = EntityState.Modified; + var existing = await _db.Lifecycles + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == entity.UserKey, + ct); - _db.Entry(projection) - .Property(x => x.Version) - .OriginalValue = expectedVersion; + if (existing is null) + throw new UAuthNotFoundException("user_lifecycle_not_found"); - try - { - await _db.SaveChangesAsync(ct); - } - catch (DbUpdateConcurrencyException) - { + 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) @@ -82,7 +84,7 @@ public async Task DeleteAsync(UserLifecycleKey key, long expectedVersion, Delete var projection = await _db.Lifecycles .SingleOrDefaultAsync( - x => x.Tenant == key.Tenant && + x => x.Tenant == _tenant && x.UserKey == key.UserKey, ct); @@ -105,7 +107,7 @@ public async Task DeleteAsync(UserLifecycleKey key, long expectedVersion, Delete await _db.SaveChangesAsync(ct); } - public async Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) + public async Task> QueryAsync(UserLifecycleQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -113,7 +115,7 @@ public async Task> QueryAsync(TenantKey tenant, UserL var baseQuery = _db.Lifecycles .AsNoTracking() - .Where(x => x.Tenant == tenant); + .Where(x => x.Tenant == _tenant); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => x.DeletedAt == null); @@ -124,19 +126,13 @@ public async Task> QueryAsync(TenantKey tenant, UserL baseQuery = query.SortBy switch { nameof(UserLifecycle.Id) => - query.Descending - ? baseQuery.OrderByDescending(x => x.Id) - : baseQuery.OrderBy(x => x.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), + 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), + query.Descending ? baseQuery.OrderByDescending(x => x.Status) : baseQuery.OrderBy(x => x.Status), _ => baseQuery.OrderBy(x => x.CreatedAt) }; 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..9a346c7a --- /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 +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserLifecycleStoreFactory(UAuthUserDbContext 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 index cf7286de..a3772ddb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -10,10 +10,12 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; internal sealed class EfCoreUserProfileStore : IUserProfileStore { private readonly UAuthUserDbContext _db; + private readonly TenantKey _tenant; - public EfCoreUserProfileStore(UAuthUserDbContext db) + public EfCoreUserProfileStore(UAuthUserDbContext db, TenantContext tenant) { _db = db; + _tenant = tenant.Tenant; } public async Task GetAsync(UserProfileKey key, CancellationToken ct = default) @@ -23,7 +25,7 @@ public EfCoreUserProfileStore(UAuthUserDbContext db) var projection = await _db.Profiles .AsNoTracking() .SingleOrDefaultAsync(x => - x.Tenant == key.Tenant && + x.Tenant == _tenant && x.UserKey == key.UserKey, ct); @@ -36,7 +38,7 @@ public async Task ExistsAsync(UserProfileKey key, CancellationToken ct = d return await _db.Profiles .AnyAsync(x => - x.Tenant == key.Tenant && + x.Tenant == _tenant && x.UserKey == key.UserKey, ct); } @@ -46,7 +48,12 @@ 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."); + _db.Profiles.Add(projection); + await _db.SaveChangesAsync(ct); } @@ -54,13 +61,21 @@ public async Task SaveAsync(UserProfile entity, long expectedVersion, Cancellati { ct.ThrowIfCancellationRequested(); - var projection = entity.ToProjection(); + var existing = await _db.Profiles + .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"); - if (entity.Version != expectedVersion + 1) - throw new InvalidOperationException("Profile version must be incremented by domain."); + entity.UpdateProjection(existing); + existing.Version++; - _db.Entry(projection).State = EntityState.Modified; - _db.Entry(projection).Property(x => x.Version).OriginalValue = expectedVersion; await _db.SaveChangesAsync(ct); } @@ -70,7 +85,7 @@ public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMo var projection = await _db.Profiles .SingleOrDefaultAsync(x => - x.Tenant == key.Tenant && + x.Tenant == _tenant && x.UserKey == key.UserKey, ct); @@ -93,7 +108,7 @@ public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMo await _db.SaveChangesAsync(ct); } - public async Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) + public async Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -101,7 +116,7 @@ public async Task> QueryAsync(TenantKey tenant, UserPro var baseQuery = _db.Profiles .AsNoTracking() - .Where(x => x.Tenant == tenant); + .Where(x => x.Tenant == _tenant); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => x.DeletedAt == null); @@ -147,13 +162,13 @@ public async Task> QueryAsync(TenantKey tenant, UserPro query.Descending); } - public async Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + public async Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var projections = await _db.Profiles .AsNoTracking() - .Where(x => x.Tenant == tenant) + .Where(x => x.Tenant == _tenant) .Where(x => userKeys.Contains(x.UserKey)) .Where(x => x.DeletedAt == null) .ToListAsync(ct); 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index fa0680d1..85195f3b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj @@ -2,17 +2,31 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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 index 9492398b..3d585691 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -11,9 +11,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.TryAddSingleton, InMemoryUserIdProvider>(); // Seed never try add diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs index 74a3a233..0d6f4ec8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs @@ -1,5 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.InMemory; namespace CodeBeam.UltimateAuth.Users.InMemory; diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index 2335b208..659f30d2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; @@ -12,25 +12,25 @@ internal sealed class InMemoryUserSeedContributor : ISeedContributor { public int Order => 0; - private readonly IUserLifecycleStore _lifecycle; - private readonly IUserProfileStore _profiles; - private readonly IUserIdentifierStore _identifiers; + private readonly IUserLifecycleStoreFactory _lifecycleFactory; + private readonly IUserIdentifierStoreFactory _identifierFactory; + private readonly IUserProfileStoreFactory _profileFactory; private readonly IInMemoryUserIdProvider _ids; private readonly IIdentifierNormalizer _identifierNormalizer; private readonly IClock _clock; public InMemoryUserSeedContributor( - IUserLifecycleStore lifecycle, - IUserProfileStore profiles, - IUserIdentifierStore identifiers, + IUserLifecycleStoreFactory lifecycleFactory, + IUserProfileStoreFactory profileFactory, + IUserIdentifierStoreFactory identifierFactory, IInMemoryUserIdProvider ids, IIdentifierNormalizer identifierNormalizer, IClock clock) { - _lifecycle = lifecycle; - _profiles = profiles; + _lifecycleFactory = lifecycleFactory; + _identifierFactory = identifierFactory; + _profileFactory = profileFactory; _ids = ids; - _identifiers = identifiers; _identifierNormalizer = identifierNormalizer; _clock = clock; } @@ -43,47 +43,60 @@ public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string username, string email, string phone, CancellationToken ct) { - var userLifecycleKey = new UserLifecycleKey(tenant, userKey); - if (await _lifecycle.ExistsAsync(userLifecycleKey, ct)) - return; - - await _lifecycle.AddAsync(UserLifecycle.Create(tenant, userKey, _clock.UtcNow), ct); - await _profiles.AddAsync(UserProfile.Create(_clock.UtcNow, tenant, userKey, displayName: displayName), ct); - - await _identifiers.AddAsync( - UserIdentifier.Create( - Guid.NewGuid(), - tenant, - userKey, - UserIdentifierType.Username, - username, - _identifierNormalizer.Normalize(UserIdentifierType.Username, username).Normalized, - _clock.UtcNow, - true, - _clock.UtcNow), ct); - - await _identifiers.AddAsync( - UserIdentifier.Create( - Guid.NewGuid(), - tenant, - userKey, - UserIdentifierType.Email, - email, - _identifierNormalizer.Normalize(UserIdentifierType.Email, email).Normalized, - _clock.UtcNow, - true, - _clock.UtcNow), ct); - - await _identifiers.AddAsync( - UserIdentifier.Create( - Guid.NewGuid(), - tenant, - userKey, - UserIdentifierType.Phone, - phone, - _identifierNormalizer.Normalize(UserIdentifierType.Phone, phone).Normalized, - _clock.UtcNow, - true, - _clock.UtcNow), ct); + var now = _clock.UtcNow; + + var lifecycleStore = _lifecycleFactory.Create(tenant); + var profileStore = _profileFactory.Create(tenant); + var identifierStore = _identifierFactory.Create(tenant); + + var lifecycleKey = new UserLifecycleKey(tenant, userKey); + + var exists = await lifecycleStore.ExistsAsync(lifecycleKey, ct); + + if (!exists) + { + await lifecycleStore.AddAsync( + UserLifecycle.Create(tenant, userKey, now), + ct); + } + + var profileKey = new UserProfileKey(tenant, userKey); + if (!await profileStore.ExistsAsync(profileKey, ct)) + { + await profileStore.AddAsync( + UserProfile.Create(Guid.NewGuid(), tenant, userKey, now, displayName: displayName), + ct); + } + + async Task EnsureIdentifier( + UserIdentifierType type, + string value, + bool isPrimary) + { + var normalized = _identifierNormalizer + .Normalize(type, value).Normalized; + + var existing = await identifierStore.GetAsync(type, normalized, ct); + + if (existing is not null) + return; + + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + type, + value, + normalized, + now, + isPrimary, + now), + ct); + } + + await EnsureIdentifier(UserIdentifierType.Username, username, true); + await EnsureIdentifier(UserIdentifierType.Email, email, true); + await EnsureIdentifier(UserIdentifierType.Phone, phone, true); } } 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 index b99c7604..cd624dbc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -1,25 +1,29 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +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 : InMemoryVersionedStore, IUserIdentifierStore +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 = Values() + var candidates = TenantValues() .Where(x => - x.Tenant == query.Tenant && x.Type == query.Type && x.NormalizedValue == query.NormalizedValue && !x.IsDeleted); @@ -50,18 +54,17 @@ public Task ExistsAsync(IdentifierExistenceQuery quer new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); } - public Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) + public Task GetAsync(UserIdentifierType type, string normalizedValue, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var identifier = Values() + var identifier = TenantValues() .FirstOrDefault(x => - x.Tenant == tenant && x.Type == type && x.NormalizedValue == normalizedValue && !x.IsDeleted); - return Task.FromResult(identifier); + return Task.FromResult(identifier?.Snapshot()); } public Task GetByIdAsync(Guid id, CancellationToken ct = default) @@ -69,12 +72,11 @@ public Task ExistsAsync(IdentifierExistenceQuery quer return GetAsync(id, ct); } - public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var result = Values() - .Where(x => x.Tenant == tenant) + var result = TenantValues() .Where(x => x.UserKey == userKey) .Where(x => !x.IsDeleted) .OrderBy(x => x.CreatedAt) @@ -134,7 +136,7 @@ public override Task SaveAsync(UserIdentifier entity, long expectedVersion, Canc } } - public Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) + public Task> QueryAsync(UserIdentifierQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -143,8 +145,7 @@ public Task> QueryAsync(TenantKey tenant, UserIdenti var normalized = query.Normalize(); - var baseQuery = Values() - .Where(x => x.Tenant == tenant) + var baseQuery = TenantValues() .Where(x => x.UserKey == query.UserKey.Value); if (!query.IncludeDeleted) @@ -195,14 +196,13 @@ public Task> QueryAsync(TenantKey tenant, UserIdenti } - public Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + public Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var set = userKeys.ToHashSet(); - var result = Values() - .Where(x => x.Tenant == tenant) + var result = TenantValues() .Where(x => set.Contains(x.UserKey)) .Where(x => !x.IsDeleted) .Select(x => x.Snapshot()) @@ -212,12 +212,12 @@ public Task> GetByUsersAsync(TenantKey tenant, IRe return Task.FromResult>(result); } - public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var identifiers = Values() - .Where(x => x.Tenant == tenant && x.UserKey == userKey && !x.IsDeleted) + var identifiers = TenantValues() + .Where(x => x.UserKey == userKey && !x.IsDeleted) .ToList(); foreach (var identifier in identifiers) 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 index 912fd90b..4546acfc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -1,23 +1,25 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +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 : InMemoryVersionedStore, IUserLifecycleStore +public sealed class InMemoryUserLifecycleStore : InMemoryTenantVersionedStore, IUserLifecycleStore { protected override UserLifecycleKey GetKey(UserLifecycle entity) => new(entity.Tenant, entity.UserKey); - public Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) + public InMemoryUserLifecycleStore(TenantContext tenant) : base(tenant) + { + } + + public Task> QueryAsync(UserLifecycleQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var normalized = query.Normalize(); - - var baseQuery = Values() - .Where(x => x.Tenant == tenant); + var baseQuery = TenantValues().AsQueryable(); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => !x.IsDeleted); @@ -42,11 +44,6 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc ? baseQuery.OrderByDescending(x => x.Status) : baseQuery.OrderBy(x => x.Status), - nameof(UserLifecycle.Tenant) => - query.Descending - ? baseQuery.OrderByDescending(x => x.Tenant.Value) - : baseQuery.OrderBy(x => x.Tenant.Value), - nameof(UserLifecycle.UserKey) => query.Descending ? baseQuery.OrderByDescending(x => x.UserKey.Value) @@ -61,7 +58,7 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc }; var totalCount = baseQuery.Count(); - var items = baseQuery.Skip((normalized.PageNumber - 1) * normalized.PageSize).Take(normalized.PageSize).ToList().AsReadOnly(); + 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 index d24ff036..38195576 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,24 +1,26 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +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 : InMemoryVersionedStore, IUserProfileStore +public sealed class InMemoryUserProfileStore : InMemoryTenantVersionedStore, IUserProfileStore { protected override UserProfileKey GetKey(UserProfile entity) => new(entity.Tenant, entity.UserKey); - public Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) + public InMemoryUserProfileStore(TenantContext tenant) : base(tenant) + { + } + + public Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var normalized = query.Normalize(); - - var baseQuery = Values() - .Where(x => x.Tenant == tenant); + var baseQuery = TenantValues().AsQueryable(); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => !x.IsDeleted); @@ -53,6 +55,7 @@ public Task> QueryAsync(TenantKey tenant, UserProfileQu var items = baseQuery .Skip((normalized.PageNumber - 1) * normalized.PageSize) .Take(normalized.PageSize) + .Select(x => x.Snapshot()) .ToList() .AsReadOnly(); @@ -66,14 +69,13 @@ public Task> QueryAsync(TenantKey tenant, UserProfileQu query.Descending)); } - public Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + public Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var set = userKeys.ToHashSet(); - var result = Values() - .Where(x => x.Tenant == tenant) + var result = TenantValues() .Where(x => set.Contains(x.UserKey)) .Where(x => !x.IsDeleted) .Select(x => x.Snapshot()) 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 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 index d64f8988..fca9576a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj @@ -2,18 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index f47dee0b..1f00216e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -5,7 +5,8 @@ using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class UserIdentifier : IVersionedEntity, ISoftDeletable, IEntitySnapshot + +public sealed class UserIdentifier : ITenantEntity, IVersionedEntity, ISoftDeletable, IEntitySnapshot { public Guid Id { get; private set; } public TenantKey Tenant { get; private set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index b0b64139..0d4d7d04 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class UserLifecycle : IVersionedEntity, ISoftDeletable, IEntitySnapshot +public sealed class UserLifecycle : ITenantEntity, IVersionedEntity, ISoftDeletable, IEntitySnapshot { private UserLifecycle() { } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index fbf58740..9bc18905 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; // TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) -public sealed class UserProfile : IVersionedEntity, ISoftDeletable, IEntitySnapshot +public sealed class UserProfile : ITenantEntity, IVersionedEntity, ISoftDeletable, IEntitySnapshot { private UserProfile() { } @@ -62,10 +62,10 @@ public UserProfile Snapshot() } public static UserProfile Create( - DateTimeOffset createdAt, + Guid? id, TenantKey tenant, UserKey userKey, - Guid? id = null, + DateTimeOffset createdAt, string? firstName = null, string? lastName = null, string? displayName = null, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs index 5a061172..9a1350e0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs @@ -7,18 +7,18 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public sealed class LoginIdentifierResolver : ILoginIdentifierResolver { - private readonly IUserIdentifierStore _store; + private readonly IUserIdentifierStoreFactory _storeFactory; private readonly IIdentifierNormalizer _normalizer; private readonly IEnumerable _customResolvers; private readonly UAuthLoginIdentifierOptions _options; public LoginIdentifierResolver( - IUserIdentifierStore store, + IUserIdentifierStoreFactory storeFactory, IIdentifierNormalizer normalizer, IEnumerable customResolvers, IOptions options) { - _store = store; + _storeFactory = storeFactory; _normalizer = normalizer; _customResolvers = customResolvers; _options = options.Value.LoginIdentifiers; @@ -58,7 +58,8 @@ public LoginIdentifierResolver( return null; } - var found = await _store.GetAsync(tenant, builtInType, normalized, ct); + var store = _storeFactory.Create(tenant); + var found = await store.GetAsync(builtInType, normalized, ct); if (found is null || !found.IsPrimary) { if (_options.EnableCustomResolvers && !_options.CustomResolversFirst) diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.cs index 74dc264e..1e096928 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.cs @@ -6,16 +6,17 @@ namespace CodeBeam.UltimateAuth.Users.Reference; internal sealed class PrimaryUserIdentifierProvider : IPrimaryUserIdentifierProvider { - private readonly IUserIdentifierStore _store; + private readonly IUserIdentifierStoreFactory _storeFactory; - public PrimaryUserIdentifierProvider(IUserIdentifierStore store) + public PrimaryUserIdentifierProvider(IUserIdentifierStoreFactory storeFactory) { - _store = store; + _storeFactory = storeFactory; } public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var identifiers = await _store.GetByUserAsync(tenant, userKey, ct); + var store = _storeFactory.Create(tenant); + var identifiers = await store.GetByUserAsync(userKey, ct); var primary = identifiers.Where(x => x.IsPrimary).ToList(); if (primary.Count == 0) diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs index 82350491..34a9d8cb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs @@ -6,16 +6,17 @@ namespace CodeBeam.UltimateAuth.Users.Reference; internal sealed class UserLifecycleSnapshotProvider : IUserLifecycleSnapshotProvider { - private readonly IUserLifecycleStore _store; + private readonly IUserLifecycleStoreFactory _storeFactory; - public UserLifecycleSnapshotProvider(IUserLifecycleStore store) + public UserLifecycleSnapshotProvider(IUserLifecycleStoreFactory storeFactory) { - _store = store; + _storeFactory = storeFactory; } public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var profile = await _store.GetAsync(new UserLifecycleKey(tenant, userKey), ct); + var store = _storeFactory.Create(tenant); + var profile = await store.GetAsync(new UserLifecycleKey(tenant, userKey), ct); if (profile is null || profile.IsDeleted) return null; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs index f3ccc480..72cbe134 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs @@ -6,16 +6,17 @@ namespace CodeBeam.UltimateAuth.Users.Reference; internal sealed class UserProfileSnapshotProvider : IUserProfileSnapshotProvider { - private readonly IUserProfileStore _store; + private readonly IUserProfileStoreFactory _storeFactory; - public UserProfileSnapshotProvider(IUserProfileStore store) + public UserProfileSnapshotProvider(IUserProfileStoreFactory storeFactory) { - _store = store; + _storeFactory = storeFactory; } public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var profile = await _store.GetAsync(new UserProfileKey(tenant, userKey), ct); + var store = _storeFactory.Create(tenant); + var profile = await store.GetAsync(new UserProfileKey(tenant, userKey), ct); if (profile is null || profile.IsDeleted) return null; 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/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 0044d49c..b122e142 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -6,6 +6,7 @@ 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; @@ -13,9 +14,9 @@ namespace CodeBeam.UltimateAuth.Users.Reference; internal sealed class UserApplicationService : IUserApplicationService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserLifecycleStore _lifecycleStore; - private readonly IUserProfileStore _profileStore; - private readonly IUserIdentifierStore _identifierStore; + private readonly IUserLifecycleStoreFactory _lifecycleStoreFactory; + private readonly IUserIdentifierStoreFactory _identifierStoreFactory; + private readonly IUserProfileStoreFactory _profileStoreFactory; private readonly IUserCreateValidator _userCreateValidator; private readonly IIdentifierValidator _identifierValidator; private readonly IEnumerable _integrations; @@ -26,9 +27,9 @@ internal sealed class UserApplicationService : IUserApplicationService public UserApplicationService( IAccessOrchestrator accessOrchestrator, - IUserLifecycleStore lifecycleStore, - IUserProfileStore profileStore, - IUserIdentifierStore identifierStore, + IUserLifecycleStoreFactory lifecycleStoreFactory, + IUserIdentifierStoreFactory identifierStoreFactory, + IUserProfileStoreFactory profileStoreFactory, IUserCreateValidator userCreateValidator, IIdentifierValidator identifierValidator, IEnumerable integrations, @@ -38,9 +39,9 @@ public UserApplicationService( IClock clock) { _accessOrchestrator = accessOrchestrator; - _lifecycleStore = lifecycleStore; - _profileStore = profileStore; - _identifierStore = identifierStore; + _lifecycleStoreFactory = lifecycleStoreFactory; + _identifierStoreFactory = identifierStoreFactory; + _profileStoreFactory = profileStoreFactory; _userCreateValidator = userCreateValidator; _identifierValidator = identifierValidator; _integrations = integrations; @@ -65,13 +66,16 @@ public async Task CreateUserAsync(AccessContext context, Creat var now = _clock.UtcNow; var userKey = UserKey.New(); - await _lifecycleStore.AddAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + await lifecycleStore.AddAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); - await _profileStore.AddAsync( + var profileStore = _profileStoreFactory.Create(context.ResourceTenant); + await profileStore.AddAsync( UserProfile.Create( - now, + Guid.NewGuid(), context.ResourceTenant, userKey, + now, firstName: request.FirstName, lastName: request.LastName, displayName: request.DisplayName ?? request.UserName ?? request.Email ?? request.Phone, @@ -82,9 +86,10 @@ await _profileStore.AddAsync( timezone: request.TimeZone, culture: request.Culture), innerCt); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); if (!string.IsNullOrWhiteSpace(request.UserName)) { - await _identifierStore.AddAsync( + await identifierStore.AddAsync( UserIdentifier.Create( Guid.NewGuid(), context.ResourceTenant, @@ -99,7 +104,7 @@ await _identifierStore.AddAsync( if (!string.IsNullOrWhiteSpace(request.Email)) { - await _identifierStore.AddAsync( + await identifierStore.AddAsync( UserIdentifier.Create( Guid.NewGuid(), context.ResourceTenant, @@ -114,7 +119,7 @@ await _identifierStore.AddAsync( if (!string.IsNullOrWhiteSpace(request.Phone)) { - await _identifierStore.AddAsync( + await identifierStore.AddAsync( UserIdentifier.Create( Guid.NewGuid(), context.ResourceTenant, @@ -152,7 +157,8 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C var targetUserKey = context.GetTargetUserKey(); var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); - var current = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + var current = await lifecycleStore.GetAsync(userLifecycleKey, innerCt); var now = _clock.UtcNow; if (current is null) @@ -167,7 +173,7 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C throw new UAuthConflictException("admin_cannot_set_self_status"); } var newEntity = current.ChangeStatus(now, newStatus); - await _lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); + await lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -181,20 +187,23 @@ public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = de var now = _clock.UtcNow; var lifecycleKey = new UserLifecycleKey(context.ResourceTenant, userKey); - var lifecycle = await _lifecycleStore.GetAsync(lifecycleKey, innerCt); + 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); + var profile = await profileStore.GetAsync(profileKey, innerCt); - await _lifecycleStore.DeleteAsync(lifecycleKey, lifecycle.Version, DeleteMode.Soft, now, innerCt); - await _identifierStore.DeleteByUserAsync(context.ResourceTenant, userKey, DeleteMode.Soft, now, 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); + await profileStore.DeleteAsync(profileKey, profile.Version, DeleteMode.Soft, now, innerCt); } foreach (var integration in _integrations) @@ -203,7 +212,7 @@ public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = de } var sessionStore = _sessionStoreFactory.Create(context.ResourceTenant); - await sessionStore.RevokeAllChainsAsync(context.ResourceTenant, userKey, now, innerCt); + await sessionStore.RevokeAllChainsAsync(userKey, now, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -216,20 +225,22 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque var targetUserKey = context.GetTargetUserKey(); var now = _clock.UtcNow; var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); - - var lifecycle = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); + 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(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + 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); + await profileStore.DeleteAsync(profileKey, profile.Version, request.Mode, now, innerCt); } foreach (var integration in _integrations) @@ -280,8 +291,8 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq var now = _clock.UtcNow; var key = new UserProfileKey(tenant, userKey); - - var profile = await _profileStore.GetAsync(key, innerCt); + var profileStore = _profileStoreFactory.Create(tenant); + var profile = await profileStore.GetAsync(key, innerCt); if (profile is null) throw new UAuthNotFoundException(); @@ -294,7 +305,7 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq .UpdateLocalization(request.Language, request.TimeZone, request.Culture, now) .UpdateMetadata(request.Metadata, now); - await _profileStore.SaveAsync(profile, expectedVersion, innerCt); + await profileStore.SaveAsync(profile, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -313,8 +324,8 @@ public async Task> GetIdentifiersByUserAsync(Acc query ??= new UserIdentifierQuery(); query.UserKey = targetUserKey; - - var result = await _identifierStore.QueryAsync(context.ResourceTenant, query, innerCt); + 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( @@ -337,7 +348,8 @@ public async Task> GetIdentifiersByUserAsync(Acc if (!normalized.IsValid) return null; - var identifier = await _identifierStore.GetAsync(context.ResourceTenant, type, normalized.Normalized, innerCt); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetAsync(type, normalized.Normalized, innerCt); return identifier is null ? null : UserIdentifierMapper.ToDto(identifier); }); @@ -354,7 +366,8 @@ public async Task UserIdentifierExistsAsync(AccessContext context, UserIde UserKey? userKey = scope == IdentifierExistenceScope.WithinUser ? context.GetTargetUserKey() : null; - var result = await _identifierStore.ExistsAsync(new IdentifierExistenceQuery(context.ResourceTenant, type, normalized.Normalized, scope, userKey), innerCt); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var result = await identifierStore.ExistsAsync(new IdentifierExistenceQuery(type, normalized.Normalized, scope, userKey), innerCt); return result.Exists; }); @@ -379,11 +392,12 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie if (!normalized.IsValid) throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); - var existing = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var existing = await identifierStore.GetByUserAsync(userKey, innerCt); EnsureMultipleIdentifierAllowed(request.Type, existing); - var userScopeResult = await _identifierStore.ExistsAsync( - new IdentifierExistenceQuery(context.ResourceTenant, request.Type, normalized.Normalized, IdentifierExistenceScope.WithinUser, UserKey: userKey), innerCt); + 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"); @@ -397,9 +411,8 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie ? IdentifierExistenceScope.TenantAny : IdentifierExistenceScope.TenantPrimaryOnly; - var globalResult = await _identifierStore.ExistsAsync( + var globalResult = await identifierStore.ExistsAsync( new IdentifierExistenceQuery( - context.ResourceTenant, request.Type, normalized.Normalized, scope), @@ -416,7 +429,7 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie EnsureVerificationRequirements(request.Type, isVerified: false); } - await _identifierStore.AddAsync( + await identifierStore.AddAsync( UserIdentifier.Create( Guid.NewGuid(), context.ResourceTenant, @@ -437,7 +450,8 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde { EnsureOverrideAllowed(context); - var identifier = await _identifierStore.GetByIdAsync(request.Id, innerCt); + 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"); @@ -461,9 +475,8 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (string.Equals(identifier.NormalizedValue, normalized.Normalized, StringComparison.Ordinal)) throw new UAuthIdentifierValidationException("identifier_value_unchanged"); - var withinUserResult = await _identifierStore.ExistsAsync( + var withinUserResult = await identifierStore.ExistsAsync( new IdentifierExistenceQuery( - identifier.Tenant, identifier.Type, normalized.Normalized, IdentifierExistenceScope.WithinUser, @@ -483,9 +496,8 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde ? IdentifierExistenceScope.TenantAny : IdentifierExistenceScope.TenantPrimaryOnly; - var result = await _identifierStore.ExistsAsync( + var result = await identifierStore.ExistsAsync( new IdentifierExistenceQuery( - identifier.Tenant, identifier.Type, normalized.Normalized, scope, @@ -499,7 +511,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde var expectedVersion = identifier.Version; identifier.ChangeValue(request.NewValue, normalized.Normalized, _clock.UtcNow); - await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -511,7 +523,8 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar { EnsureOverrideAllowed(context); - var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.IdentifierId, innerCt); if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); @@ -520,15 +533,15 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); - var result = await _identifierStore.ExistsAsync( - new IdentifierExistenceQuery(identifier.Tenant, identifier.Type, identifier.NormalizedValue, IdentifierExistenceScope.TenantPrimaryOnly, ExcludeIdentifierId: identifier.Id), innerCt); + 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 identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -540,7 +553,8 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr { EnsureOverrideAllowed(context); - var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.IdentifierId, innerCt); if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); @@ -548,7 +562,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr throw new UAuthIdentifierValidationException("identifier_already_not_primary"); var userIdentifiers = - await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); + await identifierStore.GetByUserAsync(identifier.UserKey, innerCt); var activeLoginPrimaries = userIdentifiers .Where(i => @@ -565,7 +579,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr var expectedVersion = identifier.Version; identifier.UnsetPrimary(_clock.UtcNow); - await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -577,13 +591,14 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde { EnsureOverrideAllowed(context); - var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.IdentifierId, 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 identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -595,11 +610,12 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde { EnsureOverrideAllowed(context); - var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.IdentifierId, innerCt); if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); - var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); + var identifiers = await identifierStore.GetByUserAsync(identifier.UserKey, innerCt); var loginIdentifiers = identifiers.Where(i => !i.IsDeleted && IsLoginIdentifier(i.Type)).ToList(); if (identifier.IsPrimary) @@ -622,12 +638,12 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde if (request.Mode == DeleteMode.Hard) { - await _identifierStore.DeleteAsync(identifier.Id, expectedVersion, DeleteMode.Hard, _clock.UtcNow, innerCt); + await identifierStore.DeleteAsync(identifier.Id, expectedVersion, DeleteMode.Hard, _clock.UtcNow, innerCt); } else { identifier.MarkDeleted(_clock.UtcNow); - await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); } }); @@ -641,8 +657,11 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) { - var lifecycle = await _lifecycleStore.GetAsync(new UserLifecycleKey(tenant, userKey)); - var profile = await _profileStore.GetAsync(new UserProfileKey(tenant, userKey), 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"); @@ -650,7 +669,7 @@ private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKe if (profile is null || profile.IsDeleted) throw new UAuthNotFoundException("user_profile_not_found"); - var identifiers = await _identifierStore.GetByUserAsync(tenant, userKey, ct); + 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); @@ -738,7 +757,8 @@ public async Task> QueryUsersAsync(AccessContext contex IncludeDeleted = query.IncludeDeleted }; - var lifecycleResult = await _lifecycleStore.QueryAsync(context.ResourceTenant, lifecycleQuery, innerCt); + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + var lifecycleResult = await lifecycleStore.QueryAsync(lifecycleQuery, innerCt); var lifecycles = lifecycleResult.Items; if (lifecycles.Count == 0) @@ -753,8 +773,10 @@ public async Task> QueryUsersAsync(AccessContext contex } var userKeys = lifecycles.Select(x => x.UserKey).ToList(); - var profiles = await _profileStore.GetByUsersAsync(context.ResourceTenant, userKeys, innerCt); - var identifiers = await _identifierStore.GetByUsersAsync(context.ResourceTenant, userKeys, innerCt); + 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()); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 407b03a3..04e004ad 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -1,7 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -10,12 +9,12 @@ public interface IUserIdentifierStore : IVersionedStore { Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default); - Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); + Task GetAsync(UserIdentifierType type, string value, CancellationToken ct = default); - Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default); + Task> QueryAsync(UserIdentifierQuery query, CancellationToken ct = default); - Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default); - Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, 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 index c930be38..c687f6da 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -1,12 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserLifecycleStore : IVersionedStore { - Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default); + 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 index dbedae45..5d63cb60 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,12 +1,11 @@ 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 interface IUserProfileStore : IVersionedStore { - Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default); - Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default); + 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 index 4a4e16fb..f7b218e8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs @@ -7,17 +7,18 @@ namespace CodeBeam.UltimateAuth.Users.Reference; internal sealed class UserRuntimeStateProvider : IUserRuntimeStateProvider { - private readonly IUserLifecycleStore _lifecycleStore; + private readonly IUserLifecycleStoreFactory _lifecycleStoreFactory; - public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) + public UserRuntimeStateProvider(IUserLifecycleStoreFactory lifecycleStoreFactory) { - _lifecycleStore = lifecycleStore; + _lifecycleStoreFactory = lifecycleStoreFactory; } public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { var userLifecycleKey = new UserLifecycleKey(tenant, userKey); - var lifecycle = await _lifecycleStore.GetAsync(userLifecycleKey, ct); + var lifecycleStore = _lifecycleStoreFactory.Create(tenant); + var lifecycle = await lifecycleStore.GetAsync(userLifecycleKey, ct); if (lifecycle is null) return null; 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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 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 L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ICustomLoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/ICustomLoginIdentifierResolver.cs similarity index 100% rename from src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ICustomLoginIdentifierResolver.cs rename to src/users/CodeBeam.UltimateAuth.Users/Abstractions/ICustomLoginIdentifierResolver.cs diff --git a/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ILoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/ILoginIdentifierResolver.cs similarity index 100% rename from src/users/CodeBeam.UltimateAuth.Users/Infrastructure/ILoginIdentifierResolver.cs rename to src/users/CodeBeam.UltimateAuth.Users/Abstractions/ILoginIdentifierResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserCreateValidator.cs similarity index 83% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs rename to src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserCreateValidator.cs index 8157aeee..598cfafc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserCreateValidator.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +namespace CodeBeam.UltimateAuth.Users; public interface IUserCreateValidator { diff --git a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj index aacabccb..62f2b6a7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj @@ -2,15 +2,30 @@ net8.0;net9.0;net10.0 - enable - enable - true $(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/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 0000000000000000000000000000000000000000..aa51a469d9a9fdd0ff8ea6c0711ae28dafc8fb3f GIT binary patch literal 3551 zcmc&Xc{tSF_xCdxBiqO_iq{O4lwu2DSb;A%=F=K0+c{+32sv-c&N$~BD)Y9%>?&y> zYGCoVO#?RugJtI6?Oj4Xikq_##eExOu8#z~sJ`qwn_V95fXleF?#0s0`#q&cntE&{ zK55kr=c5yGn?Vnb{ahD{i-&@Oj@29x{I~| z%Rk>{!TK(Pw$CBSFiRt)Rdhn7l#WkGuW&mn%)<*8=ft-4vFy6s+vBM@AfTpm9=#I# z_ir~V$)f(``KpAok7R%?KMxzKZi%>38gID@G!89Uq{gUNdTfWiu4=4PtnU9R1*`2f ze#5fNLm&R(75n_%#6E7_D$h1J3cfn{={y@=kccUx7S|%P)qJDKt1tuL6 z4{HXr+cB&HWu=Ed-YV*f_?bc23iM<58(B&6pwre^i{SPTD6s}D(vHYh!l()6oQ8rA zH~~l!yb{8eeKeO_o;!&bhCNgj6dA%mXet28I{;`w0M3E`jw*fC=H(Le%r#``^w+V; zcGZt_zu)w#nwipjvS9P5LAz2C#zE{~cK0>fr@i`}UKKU<+|%dlL|@dmS1fib_mjvo zPSUj3W%&}QVtWvMQ0kA=*Y0`fTg2R%8tdidv|UC^&AU8(4lFjbWvbgyOc)2KNpsz6 z#gv?I_toDkJS)8W03Qj`pDw>rT^b(J{A50P>rQuhP<~LXA%cpSbH}4sT;9|UI$)$= z80@c)g@UQ#prNI|G!$T%hj?@2=Cn1?tKO@RNC60YyVW04!T7#IeTXke05$NJuLqac zX!UCBgJCf6I+C4I0{0J&{InU;Z{PjW^jo`q<|Q*!Q&%+H*yuJ0Iq zKYMY#-@)kDa{7^VpP(JTh@|@9gK@j5mTR8}e3vazM3n!<;WPKu-oT>QxYK!=-F_Lj zJ4(8zJ1+6&%kP)&4NBP15C5S}kvA}5&ojFc3tHa!9dnt_Lc$?}^T(l~EN2u13=aWt zRR&P~gu$TVzbO3sV@#s0xxUQC0mJ!j0bvQBkNrV+tI03%k$r{%5*wBk-W}<%css!J z5=UmK%qCxRCCwk7%OXgp#HKC_3jG>v4;oMgV&MG)Jc|=`h-_xdM<=sLci;Y5iV1%8 zFAJr7#X~@mnczomAxvy}!^We$|Ig^yjGauxsh*o5luu#WW;xWtmC=^0zlu%P#*8NC z(kT74pTCZDx>^CnqJ+DB!M8ryM@&04yvb93AJoW;nX30@Y31eaVM*l9X{MdQ*WWqv8l2+O~Bg3TYlN;#aT@JLXy5AopL zF-0i|$>>PdUU4iF@gvK7%b2z&;y>vj1K{3Ns#b^A<0oBp0fi3{8%Ud$&h8k|Uj5!f zh$|c67&6`2xcahv{@C3h8_PsnAgpf|aWiI8C?Gqa53>=Im#-IV^Bq~qwF$Li&vpQ{ zH@+tQGbzY>wZFDy*#Vpm0_rp{4??0LWcq->E_cP6NgVbzlN_xX(;KgGT@9NMiKysV?5pKJn0Hs--I6f{nOg!sU_}alo$0^APDUCoHQyaD0F2$uKGKLal5Am zLbXjt2UGvzk1zDQ{b6dZHN`f0GH%f{X?B5dz?nHFN0FzSF2pXXt0WAj#E!yF1$^Mr zqmf`+fxt!K-vs%OeEI)Dxd^IWu;F0r1adV~76d;%0&Dh85{@hfgB>5^&A#o!T^=l5J9n@xYn<1|^yrsml!Ne-3ol7+ z9HY;8^!4KtU)91t_TIk`UOk(AqP}vcrY7s#a3{OJVJ5kyrNO|-t)ChI=$J*gWw@V5oaJ~r7Or_sR7M(0zd56w)T$aW;@`8r<{FZCe{Cqw zlze}Y7Z4vj7uFN*%AEG5eoYlk&0QO_SRXQ?v5n6Dy3~De1#&LU(BU!myf2`vG)#S4 zm@pa0E^uh^tC-W=k)|yqPrf&*SscDu+~p|M9Uha5AsHRTO*F1-n{+_WT$^bex!pL# z{|$0tv^0HGK{2J{5sibs-1Pj_{*b>w&B*Re1c6m&ts^2Lum$YF=Tf`g%CvYMU3d7Ti(wX7kU!8wqJW`T*VFqzj5ru#pS8SY?%U zr7$K%TYXPIVAO}edyYRa@gOI?%|F#xLBGmWAyyoBhyqgu-BnEPDK;3y5zizq7@$9Azlg z=i1bA)HOV4HH#2NKnH{Ne~kKN^3q>|5Z6fWAC-C-=_IuIU{~XLecm4R%_(Aq?~i$D48BbU zS}`RG%kSSHF5OIz0ti)VZP*@@y@O81&O{t&BlAc29(@iDy zXY&OHs-Sj^G~9D^r%BwdhCr42geey2bBK18@h+j)SNU{W>>Ggne8Fd`-Od+8S)mBK zxoL%X*lH1zm^{S$4G;V``pZD0+#98}lPbGl`TMLf@^TarVN3aVYNQ;+R@m~lvf9H+ z=IH~;#EEDr-kA7=s_(5n#{9?EI<@o=nO-Y?Hx7Joi>H#rt`=Q@J{fBEckG!;b4Gbn z|MaiDhCR?Wj^2s~&yV|h->4+NR+~A}i@K&vV?(CU!358^JJ|XPD`Cs)h*+0Gfl~9) zL3Me9;V)||0S8M0yl89}Qz#WCo0!!soG0@|j37r-imY0U+5X%Z&!7nW^b4TCoHps8}MDB1EpzTAwfZPCL)JtjlrHa6C}uXN4L(9Trcu;xP0{ibHTV zPmPOK!GiE8`PBok*BYE??%U+xa1YBdt4rJ4{r!;o037zV5paVTj<=7=lpz zVUTnIbbWimND?tS#8==Hq!|cbT7G}Um2{{shjqT4C<_rjEssSHtw@g9uX_rO#0FvE zjLUOfNNll%Oe$1_DJyI4snb+7g^G&K+l0QHe$0tp*F(pVlQH1Psfb&uq$vj^#MtZg z{>tsHYz-L3pI$bAOs{(riqj^XJwFF>z$7iLs%F1QPboPY8gj9ct^Ml?zwLBOTjrZPFZU L597+M377u|47W$d literal 0 HcmV?d00001 diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs new file mode 100644 index 00000000..f9e4007a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs index d87c40f2..c3338855 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; -using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 6b7f2055..073f0b56 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -10,6 +10,7 @@ + @@ -18,13 +19,17 @@ + + + - + + @@ -32,7 +37,8 @@ - + + 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..9144d984 --- /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..36f0a519 --- /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..0332e7f6 --- /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..3970a4ed --- /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..58e608a9 --- /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..751f24f2 --- /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..4c08b7b3 --- /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..db39136f --- /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..f1b5f3b6 --- /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/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/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 26e6d480..fd082291 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -1,6 +1,4 @@ using CodeBeam.UltimateAuth.Authentication.InMemory; -using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; -using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -8,18 +6,13 @@ using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; 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.Sessions.InMemory; -using CodeBeam.UltimateAuth.Tokens.InMemory; -using CodeBeam.UltimateAuth.Users.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference; -using CodeBeam.UltimateAuth.Users.Reference.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -45,21 +38,8 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddSingleton(); // InMemory plugins - services.AddUltimateAuthUsersInMemory(); - services.AddUltimateAuthCredentialsInMemory(); - services.AddUltimateAuthInMemorySessions(); - services.AddUltimateAuthInMemoryTokens(); - services.AddUltimateAuthInMemoryAuthenticationSecurity(); - services.AddUltimateAuthAuthorizationInMemory(); - services.AddUltimateAuthUsersReference(); - services.AddUltimateAuthAuthorizationReference(); - services.AddUltimateAuthCredentialsReference(); - - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(sp => - sp.GetRequiredService()); + services.AddUltimateAuthInMemory(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); @@ -67,7 +47,15 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddSingleton(Clock); Services = services.BuildServiceProvider(); - Services.GetRequiredService().RunAsync(null).GetAwaiter().GetResult(); + + 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() @@ -100,7 +88,7 @@ public async Task LoginAsync(AuthFlowContext flow) return await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, + Tenant = TenantKeys.Single, Identifier = "user", Secret = "user" }); 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/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index 2f6aabb7..bff92f6d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -67,8 +67,9 @@ await orchestrator.LoginAsync(flow, Secret = "wrong", }); - var store = runtime.Services.GetRequiredService(); - var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.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(1); } @@ -99,8 +100,9 @@ await orchestrator.LoginAsync(flow, Secret = "user", // valid password }); - var store = runtime.Services.GetRequiredService(); - var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.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); } @@ -159,8 +161,9 @@ await orchestrator.LoginAsync(flow, Secret = "wrong", }); - var store = runtime.Services.GetRequiredService(); - var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + 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(); } @@ -214,8 +217,9 @@ await orchestrator.LoginAsync(flow, Secret = "wrong", }); - var store = runtime.Services.GetRequiredService(); - var state1 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + 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 @@ -224,7 +228,7 @@ await orchestrator.LoginAsync(flow, Identifier = "user", }); - var state2 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state2 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); state2?.FailedAttempts.Should().Be(state1!.FailedAttempts); } @@ -250,8 +254,9 @@ await orchestrator.LoginAsync(flow, }); } - var store = runtime.Services.GetRequiredService(); - var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + 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); @@ -277,8 +282,9 @@ await orchestrator.LoginAsync(flow, Secret = "wrong", }); - var store = runtime.Services.GetRequiredService(); - var state1 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + 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; @@ -290,7 +296,7 @@ await orchestrator.LoginAsync(flow, Secret = "wrong", }); - var state2 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + var state2 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); state2?.LockedUntil.Should().Be(lockedUntil); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs index dec7a17f..404a9fc2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs @@ -96,7 +96,7 @@ public async Task Get_chain_detail_should_return_sessions() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, + Tenant = TenantKeys.Single, Identifier = "user", Secret = "user" }); @@ -121,7 +121,7 @@ public async Task Revoke_chain_should_revoke_all_sessions() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, + Tenant = TenantKeys.Single, Identifier = "user", Secret = "user" }); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs index a66d3dd7..ef600fd8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -13,7 +13,7 @@ public class IdentifierConcurrencyTests [Fact] public async Task Save_should_increment_version() { - var store = new InMemoryUserIdentifierStore(); + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -34,7 +34,7 @@ public async Task Save_should_increment_version() [Fact] public async Task Delete_should_throw_when_version_conflicts() { - var store = new InMemoryUserIdentifierStore(); + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -57,7 +57,7 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task Parallel_SetPrimary_should_conflict_deterministic() { - var store = new InMemoryUserIdentifierStore(); + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -103,7 +103,7 @@ public async Task Parallel_SetPrimary_should_conflict_deterministic() [Fact] public async Task Update_should_throw_concurrency_when_versions_conflict() { - var store = new InMemoryUserIdentifierStore(); + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); var id = Guid.NewGuid(); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; @@ -130,7 +130,7 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task Parallel_updates_should_result_in_single_success_deterministic() { - var store = new InMemoryUserIdentifierStore(); + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var id = Guid.NewGuid(); @@ -181,7 +181,7 @@ public async Task Parallel_updates_should_result_in_single_success_deterministic [Fact] public async Task High_contention_updates_should_allow_only_one_success() { - var store = new InMemoryUserIdentifierStore(); + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var id = Guid.NewGuid(); @@ -227,7 +227,7 @@ public async Task High_contention_updates_should_allow_only_one_success() [Fact] public async Task High_contention_SetPrimary_should_allow_only_one_deterministic() { - var store = new InMemoryUserIdentifierStore(); + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; @@ -276,7 +276,7 @@ public async Task High_contention_SetPrimary_should_allow_only_one_deterministic [Fact] public async Task Two_identifiers_racing_for_primary_should_allow() { - var store = new InMemoryUserIdentifierStore(); + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var user = TestUsers.Admin; @@ -344,7 +344,7 @@ public async Task Two_identifiers_racing_for_primary_should_allow() Assert.Equal(2, success); Assert.Equal(0, conflicts); - var all = await store.GetByUserAsync(tenant, user); + var all = await store.GetByUserAsync(user); var primaries = all .Where(x => x.Type == UserIdentifierType.Email && x.IsPrimary) From 7f941a5765471141b261ac3a7f461b7f19a2f34e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:10:20 +0300 Subject: [PATCH 38/50] UAuthHub Sample Improvement (#25) * Interactive Login Enhancement * Some WASM TryPkceComplete Arrangements * More Fixes * Completed PKCE * Code Refactoring * More Code Refactoring * UAuthUserAgentParser and Complete DeviceContext Implementation on WASM * Fix PKCE Client Provile & RedirectUri Mismatch * Added PKCE Tests * Little Sample Change --- .../Components/App.razor | 4 +- .../Components/Layout/MainLayout.razor | 104 ++++++- .../Components/Layout/MainLayout.razor.cs | 129 ++++++++- .../Components/Pages/Home.razor | 147 +++++----- .../Components/Pages/Home.razor.cs | 226 +++++++++++---- .../Components/_Imports.razor | 1 + .../Controllers/HubLoginController.cs | 54 ---- .../DefaultUAuthHubMarker.cs | 5 - .../Program.cs | 53 +--- .../wwwroot/app.css | 16 +- .../Components/Pages/Login.razor | 2 +- .../Components/Pages/Login.razor.cs | 23 +- .../Pages/Login.razor | 2 +- .../Pages/Login.razor.cs | 15 + .../Abstractions/Device/IUserAgentParser.cs | 8 + .../Contracts/Common/IUAuthTryResult.cs | 11 + .../Contracts/Login/LoginRequest.cs | 4 +- .../Contracts/Login/LoginResult.cs | 8 + .../Contracts/Login/TryLoginResult.cs | 13 + .../Contracts/Pkce/PkceAuthorizeCommand.cs | 16 ++ .../Contracts/Pkce/PkceCompleteRequest.cs | 16 +- .../Contracts/Pkce/PkceCompleteResult.cs | 14 + .../Contracts/Pkce/PkceLoginRequest.cs | 15 - .../Contracts/Pkce/TryPkceLoginResult.cs | 13 + .../Domain/{Pkce => Auth}/AuthArtifact.cs | 0 .../Domain/{Pkce => Auth}/AuthArtifactType.cs | 1 + .../Domain/Auth/LoginPreviewArtifact.cs | 53 ++++ .../Domain/Device/UserAgentInfo.cs | 9 + .../Domain/Hub/HubErrorCode.cs | 10 + .../Domain/Hub/HubFlowArtifact.cs | 17 ++ .../Domain/Hub/HubFlowPayload.cs | 13 + .../Domain/Hub/HubFlowState.cs | 19 ++ .../Domain/Pkce/HubLoginArtifact.cs | 30 +- .../Security/AuthenticationSecurityState.cs | 13 +- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Infrastructure/UAuthUserAgentParser.cs | 85 ++++++ .../Options/UAuthLoginOptions.cs | 5 +- .../Runtime/IUAuthHubMarker.cs | 1 + .../Abstractions/IDeviceResolver.cs | 2 +- .../Auth/ClientProfileReader.cs | 14 +- .../Auth/Context/AuthFlowContextFactory.cs | 6 +- .../Auth/IClientProfileReader.cs | 2 +- .../AspNetCore/UAuthAuthenticationHandler.cs | 2 +- .../Contracts/Hub/HubBeginRequest.cs | 20 ++ .../Contracts/Hub/HubSessionResult.cs | 6 + .../Abstractions/ILoginEndpointHandler.cs | 1 + .../Abstractions/IPkceEndpointHandler.cs | 2 + .../Endpoints/LoginEndpointHandler.cs | 202 ++++++++++++-- .../Endpoints/PkceEndpointHandler.cs | 262 ++++++++++++------ .../Endpoints/UAuthEndpointRegistrar.cs | 6 + .../Endpoints/ValidateEndpointHandler.cs | 6 +- .../Extensions/DeviceExtensions.cs | 4 +- .../EndpointRouteBuilderExtensions.cs | 3 +- .../HttpContextRequestExtensions.cs | 34 +++ .../HttpContextReturnUrlExtensions.cs | 22 +- .../Extensions/ServiceCollectionExtensions.cs | 46 ++- .../UAuthApplicationBuilderExtensions.cs | 22 +- .../Extensions/UAuthHubEndpointExtensions.cs | 16 ++ .../Flows/Login/ILoginOrchestrator.cs | 5 + .../Flows/Login/LoginExecutionMode.cs | 7 + .../Flows/Login/LoginExecutionOptions.cs | 8 + .../Flows/Login/LoginOrchestrator.cs | 66 +++-- .../Flows/Login/LoginPreviewFingerprint.cs | 16 ++ .../Flows/Pkce/PkceAuthorizationValidator.cs | 6 +- .../Flows/Pkce/PkceAuthorizeRequest.cs | 6 +- .../Flows/Pkce/PkceContextSnapshot.cs | 9 +- .../ITransportCredentialResolver.cs | 2 +- .../AspNetCore/TransportCredentialResolver.cs | 96 ++----- ...lver.cs => IValidateCredentialResolver.cs} | 4 +- ...olver.cs => ValidateCredentialResolver.cs} | 19 +- .../Infrastructure/Device/DeviceResolver.cs | 97 ++----- .../Infrastructure/Hub/HandleHubEntry.cs | 78 ++++++ .../Infrastructure/Hub/HubFlowReader.cs | 2 + .../Infrastructure/Hub/UAuthHubMarker.cs | 8 + .../Infrastructure/OriginHelper.cs | 12 + .../Redirect/AuthRedirectResolver.cs | 6 +- .../Options/UAuthHubServerOptions.cs | 2 +- .../Services/Abstractions/IHubFlowService.cs | 12 + .../Services/Abstractions/IPkceService.cs | 12 + .../{ => Abstractions}/IRefreshFlowService.cs | 0 .../IRefreshTokenRotationService.cs | 0 .../ISessionApplicationService.cs | 0 .../ISessionQueryService.cs | 0 .../{ => Abstractions}/ISessionValidator.cs | 0 .../{ => Abstractions}/IUAuthFlowService.cs | 7 + .../Services/HubFlowService.cs | 81 ++++++ .../Services/PkceService.cs | 181 ++++++++++++ .../Services/UAuthFlowService.cs | 21 +- .../{ => Base}/UAuthFlowPageBase.cs | 0 .../Base/UAuthHubLayoutComponentBase.cs | 59 ++++ .../Components/Base/UAuthHubPageBase.cs | 44 +++ .../{ => Base}/UAuthReactiveComponentBase.cs | 0 .../Components/UAuthLoginForm.razor | 8 +- .../Components/UAuthLoginForm.razor.cs | 206 ++++++++++++-- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Infrastructure/ClientDeviceProvider.cs | 44 +++ .../Infrastructure/UAuthRequestClient.cs | 22 ++ .../TScripts/uauth.js | 64 +++++ .../wwwroot/uauth.min.js | 2 +- .../Abstractions/IClientDeviceProvider.cs | 8 + .../Contracts/PkceClientState.cs | 7 - .../Contracts/UAuthSubmitMode.cs | 8 + .../Infrastructure/IUAuthRequestClient.cs | 3 + .../Options/UAuthClientEndpointOptions.cs | 4 +- .../Services/Abstractions/IFlowClient.cs | 5 +- .../Services/UAuthAuthorizationClient.cs | 3 +- .../Services/UAuthFlowClient.cs | 177 ++++++++++-- .../Credentials/ChangePasswordTests.cs | 2 - .../Fake/FakeFlowClient.cs | 20 +- .../Helpers/TestAuthRuntime.cs | 1 - .../Helpers/TestDevice.cs | 1 + .../Helpers/TestHubFactory.cs | 25 ++ .../Helpers/TestPkceFactory.cs | 44 +++ .../Server/LoginOrchestratorTests.cs | 93 +++++-- .../Server/PkceTests.cs | 129 +++++++++ .../Server/RedirectTests.cs | 4 +- .../Server/TryLoginTests.cs | 36 +++ .../Sessions/SessionTests.cs | 3 - .../UserIdentifierApplicationServiceTests.cs | 2 - 119 files changed, 2925 insertions(+), 697 deletions(-) delete mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs delete mode 100644 samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Device/IUserAgentParser.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceLoginRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs rename src/CodeBeam.UltimateAuth.Core/Domain/{Pkce => Auth}/AuthArtifact.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Domain/{Pkce => Auth}/AuthArtifactType.cs (92%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Auth/LoginPreviewArtifact.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Device/UserAgentInfo.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserAgentParser.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubBeginRequest.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubSessionResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginPreviewFingerprint.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/{IFlowCredentialResolver.cs => IValidateCredentialResolver.cs} (71%) rename src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/{FlowCredentialResolver.cs => ValidateCredentialResolver.cs} (72%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/UAuthHubMarker.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/OriginHelper.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IHubFlowService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IPkceService.cs rename src/CodeBeam.UltimateAuth.Server/Services/{ => Abstractions}/IRefreshFlowService.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Services/{ => Abstractions}/IRefreshTokenRotationService.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Services/{ => Abstractions}/ISessionApplicationService.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Services/{ => Abstractions}/ISessionQueryService.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Services/{ => Abstractions}/ISessionValidator.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Services/{ => Abstractions}/IUAuthFlowService.cs (72%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs rename src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/{ => Base}/UAuthFlowPageBase.cs (100%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs rename src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/{ => Base}/UAuthReactiveComponentBase.cs (100%) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/ClientDeviceProvider.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs delete mode 100644 src/client/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHubFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPkceFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor index 71805ad6..48e265d0 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/App.razor @@ -6,11 +6,9 @@ - @* *@ - - + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor index d257eb7a..ac680f3b 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor @@ -1,11 +1,101 @@ -@using CodeBeam.UltimateAuth.Core.Abstractions -@using CodeBeam.UltimateAuth.Server.Infrastructure -@inherits LayoutComponentBase +@inherits UAuthHubLayoutComponentBase +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject NavigationManager Nav -@Body +@if (!IsHubAuthorized) +{ + + + UltimateAuth + + UAuthHub Sample -
+ + + + + + + + + + + Access Denied + + + This page cannot be accessed directly. + UAuthHub login flows can only be initiated by an authorized client application. + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + + return; +} + + + + + UltimateAuth + + UAuthHub 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 + +
+ + +
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs index d9123d59..7242adbd 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs @@ -1,7 +1,130 @@ -namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout +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.UAuthHub.Infrastructure; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout; + +public partial class MainLayout { - 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/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor index 219617bf..b1720a39 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -1,82 +1,103 @@ @page "/" @page "/login" -@using CodeBeam.UltimateAuth.Client -@using CodeBeam.UltimateAuth.Client.Authentication -@using CodeBeam.UltimateAuth.Client.Diagnostics +@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.Core.Domain -@using CodeBeam.UltimateAuth.Core.Runtime -@using CodeBeam.UltimateAuth.Server.Abstractions -@using CodeBeam.UltimateAuth.Server.Infrastructure @using CodeBeam.UltimateAuth.Server.Services @using CodeBeam.UltimateAuth.Server.Stores -@inject IUAuthStateManager StateManager -@inject IHubFlowReader HubFlowReader -@inject IHubCredentialResolver HubCredentialResolver +@using Microsoft.Extensions.Options +@inject IUAuthClient UAuthClient @inject IAuthStore AuthStore +@inject IHubFlowService HubFlowService +@inject IPkceService PkceService +@inject IHubCredentialResolver HubCredentialResolver @inject IClientStorage BrowserStorage -@inject IUAuthFlowService Flow @inject ISnackbar Snackbar -@inject IFlowCredentialResolver CredentialResolver -@inject IUAuthClient UAuthClient -@inject NavigationManager Nav -@inject IUAuthProductInfoProvider ProductInfo -@inject AuthenticationStateProvider AuthStateProvider -@inject UAuthClientDiagnostics Diagnostics - - -
- - @if (_state == null || !_state.IsActive) - { - - - - +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService +@inject IOptions Options - Access Denied - - - This page cannot be accessed directly. - UAuthHub login flows can only be initiated by an authorized client application. - + + + + + - - return; - } - - - Welcome to UltimateAuth! - - - Login - - + + + + -
+ @* *@ + + @* TODO: Enhance sample *@ + @* Forgot Password *@ + @* Don't have an account? SignUp *@ + + + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs index dc0f988e..c3258e6a 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -1,75 +1,74 @@ -using CodeBeam.UltimateAuth.Client.Contracts; +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 Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.WebUtilities; using MudBlazor; namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages; public partial class Home { - [SupplyParameterFromQuery(Name = "hub")] - public string? HubKey { get; set; } - private string? _username; private string? _password; - private HubFlowState? _state; + private UAuthClientProductInfo? _productInfo; + private UAuthLoginForm _loginForm = null!; - protected override async Task OnParametersSetAsync() - { - if (string.IsNullOrWhiteSpace(HubKey)) - { - _state = null; - return; - } + 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; - if (HubSessionId.TryParse(HubKey, out var hubSessionId)) - _state = await HubFlowReader.GetStateAsync(hubSessionId); + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); } protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender) + if (string.IsNullOrWhiteSpace(HubKey)) return; - var currentError = await BrowserStorage.GetAsync(StorageScope.Session, "uauth:last_error"); - - if (!string.IsNullOrWhiteSpace(currentError)) + if (HubState is null || !HubState.Exists) { - Snackbar.Add(ResolveErrorMessage(currentError), Severity.Error); - await BrowserStorage.RemoveAsync(StorageScope.Session, "uauth:last_error"); + return; } - var uri = Nav.ToAbsoluteUri(Nav.Uri); - var query = QueryHelpers.ParseQuery(uri.Query); - - if (query.TryGetValue("__uauth_error", out var error)) - { - await BrowserStorage.SetAsync(StorageScope.Session, "uauth:last_error", error.ToString()); - } - - if (string.IsNullOrWhiteSpace(HubKey)) + if (HubState.IsExpired) { + await ContinuePkceAsync(); return; } - if (_state is null || !_state.Exists) - return; - - if (_state?.IsActive != true) + if (HubState.Error != null && !_errorHandled) { - await StartNewPkceAsync(); - return; + _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 = _state; + var hub = HubState; if (hub is null) return; @@ -79,15 +78,54 @@ private async Task ProgrammaticPkceLogin() var credentials = await HubCredentialResolver.ResolveAsync(hubSessionId); - var request = new PkceLoginRequest + var request = new PkceCompleteRequest { Identifier = "admin", Secret = "admin", AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty, CodeVerifier = credentials?.CodeVerifier ?? string.Empty, - ReturnUrl = _state?.ReturnUrl ?? string.Empty + ReturnUrl = HubState?.ReturnUrl ?? string.Empty, + HubSessionId = HubState?.HubSessionId.Value ?? hubSessionId.Value, }; - await UAuthClient.Flows.CompletePkceLoginAsync(request); + + 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() @@ -98,7 +136,7 @@ private async Task StartNewPkceAsync() private async Task ResolveReturnUrlAsync() { - var fromContext = _state?.ReturnUrl; + var fromContext = HubState?.ReturnUrl; if (!string.IsNullOrWhiteSpace(fromContext)) return fromContext; @@ -115,18 +153,106 @@ private async Task ResolveReturnUrlAsync() return flow.ReturnUrl!; } - // Config default (recommend adding to options) - //if (!string.IsNullOrWhiteSpace(_options.Login.DefaultReturnUrl)) - // return _options.Login.DefaultReturnUrl!; - return Nav.Uri; } - - private string ResolveErrorMessage(string? errorKey) + + 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 (errorKey == "invalid") + if (errorCode == HubErrorCode.InvalidCredentials) { - return "Login failed."; + return "Invalid credentials."; } return "Failed attempt."; diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor index aada4df3..f530884f 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor @@ -10,6 +10,7 @@ @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 diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs deleted file mode 100644 index 71cb29b3..00000000 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Controllers/HubLoginController.cs +++ /dev/null @@ -1,54 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Server.Stores; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Controllers; - -[Route("auth/uauthhub")] -[IgnoreAntiforgeryToken] -public sealed class HubLoginController : Controller -{ - private readonly IAuthStore _authStore; - private readonly UAuthServerOptions _options; - private readonly IClock _clock; - - public HubLoginController(IAuthStore authStore, IOptions options, IClock clock) - { - _authStore = authStore; - _options = options.Value; - _clock = clock; - } - - [HttpPost("login")] - [IgnoreAntiforgeryToken] - public async Task BeginLogin( - [FromForm] string authorization_code, - [FromForm] string code_verifier, - [FromForm] UAuthClientProfile client_profile, - [FromForm] string? return_url) - { - var hubSessionId = HubSessionId.New(); - - var payload = new HubFlowPayload(); - payload.Set("authorization_code", authorization_code); - payload.Set("code_verifier", code_verifier); - - var artifact = new HubFlowArtifact( - hubSessionId: hubSessionId, - flowType: HubFlowType.Login, - clientProfile: client_profile, - tenant: TenantKeys.System, - returnUrl: return_url, - payload: payload, - expiresAt: _clock.UtcNow.Add(_options.Hub.FlowLifetime)); - - await _authStore.StoreAsync(new AuthArtifactKey(hubSessionId.Value), artifact, HttpContext.RequestAborted); - - return Redirect($"{_options.Hub.LoginPath}?hub={hubSessionId.Value}"); - } -} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs deleted file mode 100644 index eb5fe640..00000000 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/DefaultUAuthHubMarker.cs +++ /dev/null @@ -1,5 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Runtime; - -internal sealed class DefaultUAuthHubMarker : IUAuthHubMarker -{ -} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index d5c689a8..47a813c8 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -2,11 +2,9 @@ using CodeBeam.UltimateAuth.Client.Blazor.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; using CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure; -using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Extensions; using MudBlazor.Services; using MudExtensions.Services; @@ -14,28 +12,34 @@ var builder = WebApplication.CreateBuilder(args); -// Add services to the container. builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); - -builder.Services.AddControllers(); + .AddInteractiveServerComponents() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }); builder.Services.AddMudServices(o => { o.SnackbarConfiguration.PreventDuplicates = false; }); builder.Services.AddMudExtensions(); -//builder.Services.AddAuthorization(); - -//builder.Services.AddHttpContextAccessor(); +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(); + .AddUltimateAuthInMemory() + .AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL builder.Services.AddUltimateAuthClientBlazor(o => { @@ -43,28 +47,11 @@ o.Reauth.Behavior = ReauthBehavior.RaiseEvent; }); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); - -builder.Services.AddCors(options => -{ - options.AddPolicy("WasmSample", policy => - { - policy - .WithOrigins("https://localhost:6130") - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials() - .WithExposedHeaders("X-UAuth-Refresh"); // TODO: Add exposed headers globally - }); -}); - var app = builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } else @@ -78,26 +65,16 @@ } app.UseHttpsRedirection(); -app.UseCors("WasmSample"); app.UseUltimateAuthWithAspNetCore(); app.UseAntiforgery(); app.MapUltimateAuthEndpoints(); +app.MapUAuthHub(); app.MapStaticAssets(); -app.MapControllers(); app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); -app.MapGet("/health", () => -{ - return Results.Ok(new - { - service = "UAuthHub", - status = "ok" - }); -}); - app.Run(); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css index 671b6199..17fcfd6a 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css @@ -13,7 +13,7 @@ a, .btn-link { } .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; + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; } .content { @@ -50,15 +50,6 @@ h1:focus { 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; -} - .uauth-stack { min-height: 60vh; max-height: calc(100vh - var(--mud-appbar-height)); @@ -79,6 +70,11 @@ h1:focus { 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)); } 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 index 7687854b..f1d587c7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -73,7 +73,7 @@
public interface IUAuthHubMarker { + bool RequiresCors { get; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs index 8be92d7a..595c51a9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs @@ -8,5 +8,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions; ///
public interface IDeviceResolver { - DeviceInfo Resolve(HttpContext context); + Task ResolveAsync(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs index 067810f4..92fe58c1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs @@ -1,24 +1,32 @@ 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 UAuthClientProfile Read(HttpContext context) + public async Task ReadAsync(HttpContext context) { if (context.Request.Headers.TryGetValue(UAuthConstants.Headers.ClientProfile, out var headerValue) && TryParse(headerValue, out var headerProfile)) { return headerProfile; } - if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.ClientProfile, out var formValue) && - TryParse(formValue, out var formProfile)) + 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; } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index fdefb918..6cb5db77 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -47,7 +47,7 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp var sessionCtx = ctx.GetSessionContext(); var user = ctx.GetUserContext(); - var clientProfile = _clientProfileReader.Read(ctx); + var clientProfile = await _clientProfileReader.ReadAsync(ctx); var originalOptions = _serverOptionsProvider.GetOriginal(ctx); var effectiveOptions = _serverOptionsProvider.GetEffective(ctx, flowType, clientProfile); @@ -61,9 +61,9 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp var effectiveMode = effectiveOptions.Mode; var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions); - var deviceInfo = _deviceResolver.Resolve(ctx); + var deviceInfo = await _deviceResolver.ResolveAsync(ctx); var deviceContext = _deviceContextFactory.Create(deviceInfo); - var returnUrl = ctx.GetReturnUrl(); + var returnUrl = await ctx.GetReturnUrlAsync(); var returnUrlInfo = ReturnUrlParser.Parse(returnUrl); SessionSecurityContext? sessionSecurityContext = null; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs index e64d3d7a..1d54a7de 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs @@ -5,5 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Auth; public interface IClientProfileReader { - UAuthClientProfile Read(HttpContext context); + Task ReadAsync(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs index bb5fded8..96fff082 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -45,7 +45,7 @@ public UAuthAuthenticationHandler( protected override async Task HandleAuthenticateAsync() { - var credential = _transportCredentialResolver.Resolve(Context); + var credential = await _transportCredentialResolver.ResolveAsync(Context); if (credential is null) return AuthenticateResult.NoResult(); 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/Endpoints/Abstractions/ILoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs index 72b3df38..a275b413 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs @@ -5,4 +5,5 @@ 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/IPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs index 547dcf9b..bc5cd909 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs @@ -17,4 +17,6 @@ public interface IPkceEndpointHandler /// then issues a session or token. /// Task CompleteAsync(HttpContext ctx); + + Task TryCompleteAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index e3b0f3e6..5d6b0885 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -3,65 +3,108 @@ 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; -public sealed class LoginEndpointHandler : ILoginEndpointHandler +internal sealed class LoginEndpointHandler : ILoginEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; - private readonly IUAuthFlowService _flowService; - private readonly IClock _clock; + 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, - IUAuthFlowService flowService, - IClock clock, + IUAuthInternalFlowService internalFlowService, ICredentialResponseWriter credentialResponseWriter, - IAuthRedirectResolver redirectResolver) + IAuthRedirectResolver redirectResolver, + IAuthStore authStore, + ILoginIdentifierResolver loginIdentifierResolver, + IOptions options, + IClock clock) { _authFlow = authFlow; - _flowService = flowService; - _clock = clock; + _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 (!ctx.Request.HasFormContentType) - return Results.BadRequest("Invalid content type."); - - var form = await ctx.Request.ReadFormAsync(); + 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 identifier = form["Identifier"].ToString(); - var secret = form["Secret"].ToString(); + var suppressFailureAttempt = false; - if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret)) + if (!string.IsNullOrWhiteSpace(request.PreviewReceipt) && authFlow.Device.DeviceId is DeviceId deviceId) { - var decisionFailureInvalid = _redirectResolver.ResolveFailure(authFlow, ctx, AuthFailureReason.InvalidCredentials); - - return decisionFailureInvalid.Enabled - ? Results.Redirect(decisionFailureInvalid.TargetUrl!) - : Results.Unauthorized(); + 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 = identifier, - Secret = secret, - Tenant = authFlow.Tenant, - At = _clock.UtcNow, - RequestTokens = authFlow.AllowsTokenIssuance + Identifier = request.Identifier, + Secret = request.Secret, + Factor = CredentialType.Password, + PreviewReceipt = request.PreviewReceipt, + RequestTokens = authFlow.AllowsTokenIssuance, + Metadata = request.Metadata, }; - var result = await _flowService.LoginAsync(authFlow, flowRequest, ctx.RequestAborted); + var result = await _internalFlowService.LoginAsync(authFlow, flowRequest, + new LoginExecutionOptions + { + Mode = LoginExecutionMode.Commit, + SuppressFailureAttempt = suppressFailureAttempt, + SuppressSuccessReset = false + }, ctx.RequestAborted); if (!result.IsSuccess) { @@ -93,4 +136,111 @@ public async Task LoginAsync(HttpContext ctx) ? 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/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 419fbc16..9743e39e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -1,15 +1,19 @@ 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.Options; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Server.Endpoints; @@ -17,80 +21,64 @@ 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 UAuthServerOptions _options; private readonly ICredentialResponseWriter _credentialResponseWriter; private readonly IAuthRedirectResolver _redirectResolver; public PkceEndpointHandler( IAuthFlowContextAccessor authContext, IUAuthFlowService flow, + IPkceService pkceService, + IUAuthInternalFlowService internalFlowService, IAuthStore authStore, IPkceAuthorizationValidator validator, IClock clock, - IOptions options, ICredentialResponseWriter credentialResponseWriter, IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; + _pkceService = pkceService; + _internalFlowService = internalFlowService; _authStore = authStore; _validator = validator; _clock = clock; - _options = options.Value; _credentialResponseWriter = credentialResponseWriter; _redirectResolver = redirectResolver; } public async Task AuthorizeAsync(HttpContext ctx) { - var authContext = _authContext.Current; - - // TODO: Make PKCE flow free - if (authContext.FlowType != AuthFlowType.Login) - return Results.BadRequest("PKCE is only supported for login flow."); + var auth = _authContext.Current; var request = await ReadPkceAuthorizeRequestAsync(ctx); if (request is null) return Results.BadRequest("Invalid content type."); - if (string.IsNullOrWhiteSpace(request.CodeChallenge)) - return Results.BadRequest("code_challenge is required."); - - if (!string.Equals(request.ChallengeMethod, "S256", StringComparison.Ordinal)) - return Results.BadRequest("Only S256 challenge method is supported."); - - var authorizationCode = AuthArtifactKey.New(); - - var snapshot = new PkceContextSnapshot( - clientProfile: authContext.ClientProfile, - tenant: authContext.Tenant, - redirectUri: request.RedirectUri, - deviceId: request.DeviceId - ); - - var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds); - - var artifact = new PkceAuthorizationArtifact( - authorizationCode: authorizationCode, - codeChallenge: request.CodeChallenge, - challengeMethod: PkceChallengeMethod.S256, - expiresAt: expiresAt, - context: snapshot - ); - - await _authStore.StoreAsync(authorizationCode, artifact, ctx.RequestAborted); + 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 = authorizationCode.Value, - ExpiresIn = _options.Pkce.AuthorizationCodeLifetimeSeconds + AuthorizationCode = result.AuthorizationCode, + ExpiresIn = result.ExpiresIn }); } - public async Task CompleteAsync(HttpContext ctx) + public async Task TryCompleteAsync(HttpContext ctx) { var authContext = _authContext.Current; @@ -99,67 +87,111 @@ public async Task CompleteAsync(HttpContext ctx) var request = await ReadPkceCompleteRequestAsync(ctx); if (request is null) - return Results.BadRequest("Invalid PKCE completion payload."); + 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.ConsumeAsync(artifactKey, ctx.RequestAborted) as PkceAuthorizationArtifact; + var artifact = await _authStore.GetAsync(artifactKey, ctx.RequestAborted) as PkceAuthorizationArtifact; if (artifact is null) - return Results.Unauthorized(); // replay / expired / unknown code + { + return Results.Ok(new TryPkceLoginResult + { + Success = false, + RetryWithNewPkce = true + }); + } - var validation = _validator.Validate(artifact, request.CodeVerifier, + var validation = _validator.Validate( + artifact, + request.CodeVerifier, new PkceContextSnapshot( - clientProfile: authContext.ClientProfile, - tenant: authContext.Tenant, - redirectUri: null, - deviceId: artifact.Context.DeviceId), + clientProfile: artifact.Context.ClientProfile, + tenant: artifact.Context.Tenant, + redirectUri: artifact.Context.RedirectUri, + device: artifact.Context.Device), _clock.UtcNow); if (!validation.Success) { - artifact.RegisterAttempt(); - return await RedirectToLoginWithErrorAsync(ctx, authContext, "invalid"); + return Results.Ok(new TryPkceLoginResult + { + Success = false, + RetryWithNewPkce = true + }); } var loginRequest = new LoginRequest { Identifier = request.Identifier, Secret = request.Secret, - Tenant = authContext.Tenant, - At = _clock.UtcNow, RequestTokens = authContext.AllowsTokenIssuance }; var execution = new AuthExecutionContext { EffectiveClientProfile = artifact.Context.ClientProfile, - Device = DeviceContext.Create(DeviceId.Create(artifact.Context.DeviceId), null, null, null, null, null) + Device = artifact.Context.Device }; - var result = await _flow.LoginAsync(authContext, execution, loginRequest, ctx.RequestAborted); + var preview = await _internalFlowService.LoginAsync(authContext, execution, loginRequest, + new LoginExecutionOptions + { + Mode = LoginExecutionMode.Preview, + SuppressFailureAttempt = false, + SuppressSuccessReset = true + }, ctx.RequestAborted); - if (!result.IsSuccess) - return await RedirectToLoginWithErrorAsync(ctx, authContext, "invalid"); - - if (result.SessionId is not null) + return Results.Ok(new TryPkceLoginResult { - _credentialResponseWriter.Write(ctx, GrantKind.Session, result.SessionId.Value); - } + Success = preview.IsSuccess, + Reason = preview.FailureReason, + RemainingAttempts = preview.RemainingAttempts, + LockoutUntilUtc = preview.LockoutUntilUtc, + RequiresMfa = preview.FailureReason == AuthFailureReason.RequiresMfa, + RetryWithNewPkce = false + }); + } - if (result.AccessToken is not null) - { - _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); - } + public async Task CompleteAsync(HttpContext ctx) + { + var auth = _authContext.Current; - if (result.RefreshToken is not null) - { - _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); - } + var request = await ReadPkceCompleteRequestAsync(ctx); + if (request is null) + return Results.BadRequest("Invalid PKCE payload."); - var decision = _redirectResolver.ResolveSuccess(authContext, ctx); + 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!) @@ -175,19 +207,47 @@ public async Task CompleteAsync(HttpContext ctx) if (ctx.Request.HasFormContentType) { - var form = await ctx.Request.ReadFormAsync(ctx.RequestAborted); + 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; + } + } - var codeChallenge = form["code_challenge"].ToString(); - var challengeMethod = form["challenge_method"].ToString(); - var redirectUri = form["redirect_uri"].ToString(); - var deviceId = form["device_id"].ToString(); + 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, - DeviceId = deviceId + Device = device }; } @@ -198,27 +258,32 @@ public async Task CompleteAsync(HttpContext ctx) { if (ctx.Request.HasJsonContentType()) { - return await ctx.Request.ReadFromJsonAsync( - cancellationToken: ctx.RequestAborted); + return await ctx.Request.ReadFromJsonAsync(cancellationToken: ctx.RequestAborted); } if (ctx.Request.HasFormContentType) { - var form = await ctx.Request.ReadFormAsync(ctx.RequestAborted); + 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(); - var authorizationCode = form["authorization_code"].ToString(); - var codeVerifier = form["code_verifier"].ToString(); - var identifier = form["Identifier"].ToString(); - var secret = form["Secret"].ToString(); - var returnUrl = form["return_url"].ToString(); + 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, - Secret = secret, - ReturnUrl = returnUrl + Identifier = identifier ?? string.Empty, + Secret = secret ?? string.Empty, + ReturnUrl = returnUrl ?? string.Empty }; } @@ -228,7 +293,7 @@ public async Task CompleteAsync(HttpContext ctx) private async Task RedirectToLoginWithErrorAsync(HttpContext ctx, AuthFlowContext auth, string error) { var basePath = auth.OriginalOptions.Hub.LoginPath ?? "/login"; - var hubKey = ctx.Request.Query["hub"].ToString(); + var hubKey = await ResolveHubKeyAsync(ctx); if (!string.IsNullOrWhiteSpace(hubKey)) { @@ -237,15 +302,28 @@ private async Task RedirectToLoginWithErrorAsync(HttpContext ctx, AuthF if (artifact is HubFlowArtifact hub) { - hub.MarkCompleted(); - await _authStore.StoreAsync(key, hub, ctx.RequestAborted); + 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(); - return Results.Redirect( - $"{basePath}?hub={Uri.EscapeDataString(hubKey)}&__uauth_error={Uri.EscapeDataString(error)}"); + 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 Results.Redirect( - $"{basePath}?__uauth_error={Uri.EscapeDataString(error)}"); + return null; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 4387daee..fa1d0120 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -47,6 +47,9 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options 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)); @@ -95,6 +98,9 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options 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) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index 808c8717..9473ee3a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -11,14 +11,14 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; internal sealed class ValidateEndpointHandler : IValidateEndpointHandler { private readonly IAuthFlowContextAccessor _authContext; - private readonly IFlowCredentialResolver _credentialResolver; + private readonly IValidateCredentialResolver _credentialResolver; private readonly ISessionValidator _sessionValidator; private readonly IAuthStateSnapshotFactory _snapshotFactory; private readonly IClock _clock; public ValidateEndpointHandler( IAuthFlowContextAccessor authContext, - IFlowCredentialResolver credentialResolver, + IValidateCredentialResolver credentialResolver, ISessionValidator sessionValidator, IAuthStateSnapshotFactory snapshotFactory, IClock clock) @@ -33,7 +33,7 @@ public ValidateEndpointHandler( public async Task ValidateAsync(HttpContext context, CancellationToken ct = default) { var auth = _authContext.Current; - var credential = _credentialResolver.Resolve(context, auth.Response); + var credential = await _credentialResolver.ResolveAsync(context, auth.Response); if (credential is null) { diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs index de1bd560..5a5ec37a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs @@ -7,9 +7,9 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; public static class DeviceExtensions { - public static DeviceInfo GetDevice(this HttpContext context) + public static async Task GetDeviceAsync(this HttpContext context) { var resolver = context.RequestServices.GetRequiredService(); - return resolver.Resolve(context); + return await resolver.ResolveAsync(context); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs index deb51630..cedcbdfe 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs @@ -13,7 +13,8 @@ public static IEndpointRouteBuilder MapUltimateAuthEndpoints(this IEndpointRoute { var registrar = endpoints.ServiceProvider.GetRequiredService(); var options = endpoints.ServiceProvider.GetRequiredService>().Value; - var rootGroup = endpoints.MapGroup(""); + var rootGroup = endpoints.MapGroup("") + .RequireCors("UAuthHub"); registrar.MapEndpoints(rootGroup, options); if (endpoints is WebApplication app) 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 index 890cdf28..3ca9c43a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs @@ -5,16 +5,28 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; internal static class HttpContextReturnUrlExtensions { - public static string? GetReturnUrl(this HttpContext ctx) + public static async Task GetReturnUrlAsync(this HttpContext ctx) { - if (ctx.Request.HasFormContentType && ctx.Request.Form.TryGetValue(UAuthConstants.Form.ReturnUrl, out var form)) + if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var query)) { - return form.ToString(); + return query.ToString(); } - if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var query)) + if (ctx.Request.HasFormContentType) { - return query.ToString(); + 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/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 4778a004..589f059f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -168,7 +168,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); @@ -183,6 +185,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -203,14 +206,13 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddScoped(); services.TryAddSingleton(); services.TryAddScoped(); @@ -225,7 +227,6 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddSingleton(); - services.TryAddScoped(); services.TryAddSingleton(); services.TryAddScoped(); @@ -393,6 +394,45 @@ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceC 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; + } } internal sealed class NullTenantResolver : ITenantIdResolver diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index 5efccabe..23e99b20 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -1,5 +1,8 @@ -using CodeBeam.UltimateAuth.Server.Middlewares; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -15,8 +18,23 @@ public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app) return app; } - public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder 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(); 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/Flows/Login/ILoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs index 6fac9ccc..c2274f14 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs @@ -13,3 +13,8 @@ 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/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 index c34d264b..18734ab8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -18,7 +18,7 @@ namespace CodeBeam.UltimateAuth.Server.Flows; -internal sealed class LoginOrchestrator : ILoginOrchestrator +internal sealed class LoginOrchestrator : ILoginOrchestrator, IInternalLoginOrchestrator { private readonly ILoginIdentifierResolver _identifierResolver; private readonly IEnumerable _credentialProviders; // authentication @@ -31,6 +31,7 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator private readonly IAuthenticationSecurityManager _authenticationSecurityManager; // runtime risk private readonly UAuthEventDispatcher _events; private readonly UAuthServerOptions _options; + private readonly IClock _clock; public LoginOrchestrator( ILoginIdentifierResolver identifierResolver, @@ -43,7 +44,8 @@ public LoginOrchestrator( ISessionStoreFactory storeFactory, IAuthenticationSecurityManager authenticationSecurityManager, UAuthEventDispatcher events, - IOptions options) + IOptions options, + IClock clock) { _identifierResolver = identifierResolver; _credentialProviders = credentialProviders; @@ -56,17 +58,21 @@ public LoginOrchestrator( _authenticationSecurityManager = authenticationSecurityManager; _events = events; _options = options.Value; + _clock = clock; } - public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + 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 = request.At ?? DateTimeOffset.UtcNow; - var resolution = await _identifierResolver.ResolveAsync(request.Tenant, request.Identifier, ct); + var now = _clock.UtcNow; + var resolution = await _identifierResolver.ResolveAsync(flow.Tenant, request.Identifier, ct); var userKey = resolution?.UserKey; bool userExists = false; @@ -77,19 +83,18 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (userKey is not null) { - var user = await _users.GetAsync(request.Tenant, userKey.Value, ct); + 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(request.Tenant, userKey.Value, ct); + 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(request.Tenant, userKey.Value, request.Factor, ct); + factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.Factor, ct); if (factorState.IsLocked(now)) { @@ -98,7 +103,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req foreach (var provider in _credentialProviders) { - var credentials = await provider.GetByUserAsync(request.Tenant, userKey.Value, ct); + var credentials = await provider.GetByUserAsync(flow.Tenant, userKey.Value, ct); foreach (var credential in credentials) { @@ -119,7 +124,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } // TODO: Add create-time uniqueness guard for chain id for concurrency - var sessionStore = _storeFactory.Create(request.Tenant); + var sessionStore = _storeFactory.Create(flow.Tenant); SessionChainId? chainId = null; if (userKey is not null) @@ -133,7 +138,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req // TODO: Add accountState here, currently it only checks factor state var decisionContext = new LoginDecisionContext { - Tenant = request.Tenant, + Tenant = flow.Tenant, Identifier = request.Identifier, CredentialsValid = credentialsValid, UserExists = userExists, @@ -144,32 +149,36 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req var decision = _authority.Decide(decisionContext); - var max = _options.Login.MaxFailedAttempts; - if (decision.Kind == LoginDecisionKind.Deny) { if (userKey is not null && userExists && factorState is not null) { - var securityVersion = factorState.SecurityVersion; - factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure); - await _authenticationSecurityManager.UpdateAsync(factorState, securityVersion, ct); - 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) { - if (factorState.IsLocked(now)) + var stateForResponse = factorState; + + if (stateForResponse.IsLocked(now)) { - lockedUntil = factorState.LockedUntil; + lockedUntil = stateForResponse.LockedUntil; remainingAttempts = 0; } else if (_options.Login.MaxFailedAttempts > 0) { - remainingAttempts = _options.Login.MaxFailedAttempts - factorState.FailedAttempts; + remainingAttempts = _options.Login.MaxFailedAttempts - stateForResponse.FailedAttempts; } } + return LoginResult.Failed( factorState.IsLocked(now) ? AuthFailureReason.LockedOut @@ -194,18 +203,23 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req 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 (factorState is not null) + 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(request.Tenant, userKey.Value, ct); + var claims = await _claimsProvider.GetClaimsAsync(flow.Tenant, userKey.Value, ct); var sessionContext = new AuthenticatedSessionContext { - Tenant = request.Tenant, + Tenant = flow.Tenant, UserKey = userKey.Value, Now = now, Device = flow.Device, @@ -224,7 +238,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req { var tokenContext = new TokenIssuanceContext { - Tenant = request.Tenant, + Tenant = flow.Tenant, UserKey = userKey.Value, SessionId = issuedSession.Session.SessionId, ChainId = issuedSession.Session.ChainId, @@ -239,7 +253,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } await _events.DispatchAsync( - new UserLoggedInContext(request.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId)); + 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/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs index 1f1f22b2..ac788f61 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs @@ -11,8 +11,8 @@ public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string if (artifact.IsExpired(now)) return PkceValidationResult.Fail(PkceValidationFailureReason.ArtifactExpired); - //if (!IsContextValid(artifact.Context, completionContext)) - //return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch); + if (!IsContextValid(artifact.Context, completionContext)) + return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch); if (artifact.ChallengeMethod != PkceChallengeMethod.S256) return PkceValidationResult.Fail(PkceValidationFailureReason.UnsupportedChallengeMethod); @@ -34,7 +34,7 @@ private static bool IsContextValid(PkceContextSnapshot original, PkceContextSnap if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal)) return false; - if (!string.Equals(original.DeviceId, completion.DeviceId, StringComparison.Ordinal)) + if (!Equals(original.Device, completion.Device)) return false; return true; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs index 9b5d390b..2186a502 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs @@ -1,9 +1,11 @@ -namespace CodeBeam.UltimateAuth.Server.Flows; +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 string? DeviceId { get; init; } + public required DeviceContext Device { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs index 37c10714..289d1b84 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -14,12 +15,12 @@ public PkceContextSnapshot( UAuthClientProfile clientProfile, TenantKey tenant, string? redirectUri, - string? deviceId) + DeviceContext device) { ClientProfile = clientProfile; Tenant = tenant; RedirectUri = redirectUri; - DeviceId = deviceId; + Device = device; } /// @@ -42,5 +43,5 @@ public PkceContextSnapshot( /// Optional device binding identifier. /// Enables future hard-binding of PKCE flows to devices. /// - public string? DeviceId { get; } + public DeviceContext Device { get; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs index 22015398..a14f8437 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs @@ -4,5 +4,5 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface ITransportCredentialResolver { - TransportCredential? Resolve(HttpContext context); + ValueTask ResolveAsync(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs index 2a23d8b3..2751b2a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs @@ -14,121 +14,85 @@ public TransportCredentialResolver(IOptionsMonitor server) _server = server; } - public TransportCredential? Resolve(HttpContext context) + public async ValueTask ResolveAsync(HttpContext context) { var cookies = _server.CurrentValue.Cookie; - if (TryFromAuthorizationHeader(context, out var bearer)) - return bearer; - - if (TryFromCookies(context, cookies, out var cookie)) - return cookie; - - if (TryFromQuery(context, out var query)) - return query; - - if (TryFromBody(context, out var body)) - return body; - - if (TryFromHub(context, out var hub)) - return hub; - - return null; + 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 bool TryFromAuthorizationHeader(HttpContext ctx, out TransportCredential credential) + private static async ValueTask TryFromAuthorizationHeaderAsync(HttpContext ctx) { - credential = default!; - if (!ctx.Request.Headers.TryGetValue("Authorization", out var header)) - return false; + return null; var value = header.ToString(); if (!value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return false; + return null; var token = value["Bearer ".Length..].Trim(); if (string.IsNullOrWhiteSpace(token)) - return false; + return null; - credential = new TransportCredential + return new TransportCredential { Kind = TransportCredentialKind.AccessToken, Value = token, TenantId = ctx.GetTenant().Value, - Device = ctx.GetDevice() + Device = await ctx.GetDeviceAsync() }; - - return true; } - private static bool TryFromCookies( - HttpContext ctx, - UAuthCookiePolicyOptions cookieSet, - out TransportCredential credential) + private static async ValueTask TryFromCookiesAsync(HttpContext ctx, UAuthCookiePolicyOptions cookieSet) { - credential = default!; - - // Session cookie if (TryReadCookie(ctx, cookieSet.Session.Name, out var session)) - { - credential = Build(ctx, TransportCredentialKind.Session, session); - return true; - } + return await BuildAsync(ctx, TransportCredentialKind.Session, session); - // Refresh token cookie if (TryReadCookie(ctx, cookieSet.RefreshToken.Name, out var refresh)) - { - credential = Build(ctx, TransportCredentialKind.RefreshToken, refresh); - return true; - } + return await BuildAsync(ctx, TransportCredentialKind.RefreshToken, refresh); - // Access token cookie (optional) if (TryReadCookie(ctx, cookieSet.AccessToken.Name, out var access)) - { - credential = Build(ctx, TransportCredentialKind.AccessToken, access); - return true; - } + return await BuildAsync(ctx, TransportCredentialKind.AccessToken, access); - return false; + return null; } - private static bool TryFromQuery(HttpContext ctx, out TransportCredential credential) + private static async ValueTask TryFromQueryAsync(HttpContext ctx) { - credential = default!; - if (!ctx.Request.Query.TryGetValue("access_token", out var token)) - return false; + return null; var value = token.ToString(); if (string.IsNullOrWhiteSpace(value)) - return false; + return null; - credential = new TransportCredential + return new TransportCredential { Kind = TransportCredentialKind.AccessToken, Value = value, TenantId = ctx.GetTenant().Value, - Device = ctx.GetDevice() + Device = await ctx.GetDeviceAsync() }; - - return true; } - private static bool TryFromBody(HttpContext ctx, out TransportCredential credential) + private static ValueTask TryFromBodyAsync(HttpContext ctx) { - credential = default!; // intentionally empty for now // body parsing is expensive and opt-in later - return false; + + return ValueTask.FromResult(null); } - private static bool TryFromHub(HttpContext ctx, out TransportCredential credential) + private static ValueTask TryFromHubAsync(HttpContext ctx) { - credential = default!; // UAuthHub detection can live here later - return false; + + return ValueTask.FromResult(null); } private static bool TryReadCookie(HttpContext ctx, string name, out string value) @@ -149,12 +113,12 @@ private static bool TryReadCookie(HttpContext ctx, string name, out string value return true; } - private static TransportCredential Build(HttpContext ctx, TransportCredentialKind kind, string value) + private static async Task BuildAsync(HttpContext ctx, TransportCredentialKind kind, string value) => new() { Kind = kind, Value = value, TenantId = ctx.GetTenant().Value, - Device = ctx.GetDevice() + Device = await ctx.GetDeviceAsync() }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs similarity index 71% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs index a798db95..3a8efc49 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IFlowCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs @@ -8,7 +8,7 @@ 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 IFlowCredentialResolver +public interface IValidateCredentialResolver { - ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response); + Task ResolveAsync(HttpContext context, EffectiveAuthResponse response); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs similarity index 72% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs index 8aa3f572..1923e5c3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs @@ -8,29 +8,29 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed class FlowCredentialResolver : IFlowCredentialResolver +internal sealed class ValidateCredentialResolver : IValidateCredentialResolver { private readonly IPrimaryCredentialResolver _primaryResolver; - public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) + public ValidateCredentialResolver(IPrimaryCredentialResolver primaryResolver) { _primaryResolver = primaryResolver; } - public ResolvedCredential? Resolve(HttpContext context, EffectiveAuthResponse response) + public async Task ResolveAsync(HttpContext context, EffectiveAuthResponse response) { var kind = _primaryResolver.Resolve(context); return kind switch { - PrimaryGrantKind.Stateful => ResolveSession(context, response), - PrimaryGrantKind.Stateless => ResolveAccessToken(context, response), + PrimaryGrantKind.Stateful => await ResolveSession(context, response), + PrimaryGrantKind.Stateless => await ResolveAccessToken(context, response), _ => null }; } - private static ResolvedCredential? ResolveSession(HttpContext context, EffectiveAuthResponse response) + private static async Task ResolveSession(HttpContext context, EffectiveAuthResponse response) { var delivery = response.SessionIdDelivery; @@ -52,11 +52,11 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) Kind = PrimaryGrantKind.Stateful, Value = raw.Trim(), Tenant = context.GetTenant(), - Device = context.GetDevice() + Device = await context.GetDeviceAsync() }; } - private static ResolvedCredential? ResolveAccessToken(HttpContext context, EffectiveAuthResponse response) + private static async Task ResolveAccessToken(HttpContext context, EffectiveAuthResponse response) { var delivery = response.AccessTokenDelivery; @@ -84,8 +84,7 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) Kind = PrimaryGrantKind.Stateless, Value = value, Tenant = context.GetTenant(), - Device = context.GetDevice() + Device = await context.GetDeviceAsync() }; } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs index dbcc0a50..8fa959b7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -1,33 +1,44 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +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: This is a very basic implementation. -// Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. (Add IDeviceInfoParser) +// 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 { - public DeviceInfo Resolve(HttpContext context) + private readonly IUserAgentParser _userAgentParser; + + public DeviceResolver(IUserAgentParser userAgentParser) + { + _userAgentParser = userAgentParser; + } + + public async Task ResolveAsync(HttpContext context) { var request = context.Request; - var rawDeviceId = ResolveRawDeviceId(context); + 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, - Platform = ResolvePlatform(ua), - OperatingSystem = ResolveOperatingSystem(ua), - Browser = ResolveBrowser(ua), + DeviceType = parsed.DeviceType, + Platform = parsed.Platform, + OperatingSystem = parsed.OperatingSystem, + Browser = parsed.Browser, UserAgent = ua, IpAddress = ResolveIp(context) }; @@ -35,14 +46,21 @@ public DeviceInfo Resolve(HttpContext context) return deviceInfo; } - private static string? ResolveRawDeviceId(HttpContext context) + private static async Task ResolveRawDeviceId(HttpContext context) { if (context.Request.Headers.TryGetValue("X-UDID", out var header)) return header.ToString(); - if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.Device, out var formValue) && !StringValues.IsNullOrEmpty(formValue)) + if (context.Request.HasFormContentType) { - return formValue.ToString(); + 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)) @@ -51,63 +69,6 @@ public DeviceInfo Resolve(HttpContext context) return null; } - private static string? ResolvePlatform(string ua) - { - var s = ua.ToLowerInvariant(); - - if (s.Contains("ipad") || s.Contains("tablet") || s.Contains("sm-t") /* bazı samsung tabletler */) - return "tablet"; - - if (s.Contains("mobi") || s.Contains("iphone") || s.Contains("android")) - return "mobile"; - - return "desktop"; - } - - private static string? ResolveOperatingSystem(string ua) - { - var s = ua.ToLowerInvariant(); - - if (s.Contains("iphone") || s.Contains("ipad") || s.Contains("cpu os") || s.Contains("ios")) - return "ios"; - - if (s.Contains("android")) - return "android"; - - if (s.Contains("windows nt")) - return "windows"; - - if (s.Contains("mac os x") || s.Contains("macintosh")) - return "macos"; - - if (s.Contains("linux")) - return "linux"; - - return "unknown"; - } - - private static string? ResolveBrowser(string ua) - { - var s = ua.ToLowerInvariant(); - - if (s.Contains("edg/")) - return "edge"; - - if (s.Contains("opr/") || s.Contains("opera")) - return "opera"; - - if (s.Contains("chrome/") && !s.Contains("chromium/")) - return "chrome"; - - if (s.Contains("safari/") && !s.Contains("chrome/") && !s.Contains("crios/")) - return "safari"; - - if (s.Contains("firefox/")) - return "firefox"; - - return "unknown"; - } - private static string? ResolveIp(HttpContext context) { var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); 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/HubFlowReader.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs index 646e780a..55c96fa4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs @@ -31,6 +31,8 @@ public HubFlowReader(IAuthStore store, IClock clock) 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/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 index f137f88e..68b2e7a5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs @@ -100,11 +100,11 @@ private static void ValidateAllowed(string baseAddress, UAuthServerOptions optio if (options.Hub.AllowedClientOrigins.Count == 0) return; - if (!options.Hub.AllowedClientOrigins.Any(o => Normalize(o) == Normalize(baseAddress))) + var normalized = OriginHelper.Normalize(baseAddress); + + if (!options.Hub.AllowedClientOrigins.Any(o => OriginHelper.Normalize(o) == normalized)) { throw new InvalidOperationException($"Redirect to '{baseAddress}' is not allowed."); } } - - private static string Normalize(string uri) => uri.TrimEnd('/').ToLowerInvariant(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs index 2d904064..96b6f414 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs @@ -10,7 +10,7 @@ public sealed class UAuthHubServerOptions /// Lifetime of hub flow artifacts (UI orchestration). /// Should be short-lived. /// - public TimeSpan FlowLifetime { get; set; } = TimeSpan.FromMinutes(2); + public TimeSpan FlowLifetime { get; set; } = TimeSpan.FromMinutes(5); public string? LoginPath { get; set; } = "/login"; 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/IRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshFlowService.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Services/IRefreshFlowService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshFlowService.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshTokenRotationService.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Services/IRefreshTokenRotationService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshTokenRotationService.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionApplicationService.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionApplicationService.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionQueryService.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionQueryService.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionValidator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Services/ISessionValidator.cs rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionValidator.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs similarity index 72% rename from src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs index 54924e2a..4445c7e1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; namespace CodeBeam.UltimateAuth.Server.Services; @@ -25,3 +26,9 @@ public interface IUAuthFlowService 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/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index cc247a8e..23b7e174 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -9,11 +9,12 @@ namespace CodeBeam.UltimateAuth.Server.Services; -internal sealed class UAuthFlowService : IUAuthFlowService +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; @@ -21,12 +22,14 @@ public UAuthFlowService( IAuthFlowContextAccessor authFlow, IAuthFlowContextFactory authFlowContextFactory, ILoginOrchestrator loginOrchestrator, + IInternalLoginOrchestrator internalLoginOrchestrator, ISessionOrchestrator orchestrator, UAuthEventDispatcher events) { _authFlow = authFlow; _authFlowContextFactory = authFlowContextFactory; _loginOrchestrator = loginOrchestrator; + _internalLoginOrchestrator = internalLoginOrchestrator; _orchestrator = orchestrator; _events = events; } @@ -47,6 +50,22 @@ public async Task LoginAsync(AuthFlowContext flow, AuthExecutionCon 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; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthFlowPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs similarity index 100% rename from src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthFlowPageBase.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs 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/UAuthReactiveComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs similarity index 100% rename from src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthReactiveComponentBase.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor index 75ebab7b..93eb2736 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor @@ -12,7 +12,7 @@ @inject IOptions Options @inject NavigationManager Navigation - + @@ -23,7 +23,11 @@ { - + } + + @if (SubmitMode == UAuthSubmitMode.DirectCommit) + { + } @ChildContent diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs index bb730360..8696afd6 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs @@ -3,6 +3,7 @@ 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; @@ -11,8 +12,11 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; public partial class UAuthLoginForm { - [Inject] IDeviceIdProvider DeviceIdProvider { get; set; } = null!; - private DeviceId? _deviceId; + [Inject] + IDeviceIdProvider DeviceIdProvider { get; set; } = null!; + + [Inject] + IUAuthClient UAuthClient { get; set; } = null!; [Inject] IHubCredentialResolver HubCredentialResolver { get; set; } = null!; @@ -23,40 +27,77 @@ public partial class UAuthLoginForm [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; } - //[Parameter] - //public IHubCredentialResolver? HubCredentialResolver { get; set; } - - //[Parameter] - //public IHubFlowReader? HubFlowReader { 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; - private ElementReference _form; + /// + /// 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(); @@ -70,13 +111,6 @@ protected override async Task OnParametersSetAsync() "PKCE is not supported in this client profile." + "Change LoginType to password or place this component to a server-side project."); } - - //if (LoginType == UAuthLoginType.Pkce && EffectiveHubSessionId is null) - //{ - // throw new InvalidOperationException("PKCE login requires an active Hub flow. " + - // "No 'hub' query parameter was found." - // ); - //} } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -88,7 +122,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) StateHasChanged(); } - public async Task ReloadCredentialsAsync() + protected async Task ReloadCredentialsAsync() { if (LoginType != UAuthLoginType.Pkce) return; @@ -99,7 +133,7 @@ public async Task ReloadCredentialsAsync() _credentials = await HubCredentialResolver.ResolveAsync(EffectiveHubSessionId.Value); } - public async Task ReloadStateAsync() + protected async Task ReloadStateAsync() { if (LoginType != UAuthLoginType.Pkce || EffectiveHubSessionId is null || HubFlowReader is null) return; @@ -107,18 +141,130 @@ public async Task ReloadStateAsync() _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."); - await JS.InvokeVoidAsync("uauth.submitForm", _form); + 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.PkceComplete + ? Options.Value.Endpoints.PkceTryComplete : Options.Value.Endpoints.Login; @@ -136,13 +282,27 @@ private string ResolvedEndpoint if (string.IsNullOrWhiteSpace(returnUrl)) return baseUrl; - return $"{baseUrl}?{(_credentials != null ? "hub=" + EffectiveHubSessionId + "&" : null)}{UAuthConstants.Query.ReturnUrl}={Uri.EscapeDataString(returnUrl)}"; + 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) + private string? EffectiveReturnUrl => !string.IsNullOrWhiteSpace(ReturnUrl) ? ReturnUrl - : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl ?? string.Empty : Navigation.Uri; + : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl : Navigation.Uri; private HubSessionId? EffectiveHubSessionId { diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs index 1d3b84de..5a8f3d95 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs @@ -50,6 +50,7 @@ private static IServiceCollection AddUltimateAuthClientBlazorInternal(this IServ services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddAuthorizationCore(); 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/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index b0a2a020..bc66f3cb 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -2,6 +2,7 @@ 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; @@ -87,4 +88,25 @@ public async Task SendJsonAsync(string endpoint, object? p 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/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index 6aba29d1..a1eddceb 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -54,6 +54,62 @@ window.uauth.submitForm = function (form) { 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, @@ -178,3 +234,11 @@ window.uauth.postJson = async function (options) { 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/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index 5b835c21..28aff8b9 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(n){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.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} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(n){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/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/Contracts/PkceClientState.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs deleted file mode 100644 index a22da4f0..00000000 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/PkceClientState.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Client.Contracts; - -internal sealed class PkceClientState -{ - public string Verifier { get; init; } = default!; - public string AuthorizationCode { get; init; } = default!; -} 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/Infrastructure/IUAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs index d43838bc..98618306 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Client.Infrastructure; @@ -9,4 +10,6 @@ public interface IUAuthRequestClient 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/Options/UAuthClientEndpointOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs index f1cd1281..0b709c79 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs @@ -8,11 +8,13 @@ public sealed class UAuthClientEndpointOptions 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/login"; + public string HubLoginPath { get; set; } = "/uauthhub/entry"; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs index fade45c2..a97d3294 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -9,13 +9,16 @@ 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 CompletePkceLoginAsync(PkceLoginRequest request); + Task TryCompletePkceLoginAsync(PkceCompleteRequest request, UAuthSubmitMode mode); + Task CompletePkceLoginAsync(PkceCompleteRequest request); Task> LogoutDeviceSelfAsync(LogoutDeviceRequest request); Task LogoutOtherDevicesSelfAsync(); diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 683db2bc..425a3f27 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 85d21ab3..062de526 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -22,15 +22,17 @@ internal class UAuthFlowClient : IFlowClient { private readonly IUAuthRequestClient _post; private readonly IUAuthClientEvents _events; + private readonly IClientDeviceProvider _clientDeviceProvider; private readonly IDeviceIdProvider _deviceIdProvider; private readonly IReturnUrlProvider _returnUrlProvider; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; - public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IDeviceIdProvider deviceIdProvider, IReturnUrlProvider returnUrlProvider, IOptions options, UAuthClientDiagnostics diagnostics) + public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IClientDeviceProvider clientDeviceProvider, IDeviceIdProvider deviceIdProvider, IReturnUrlProvider returnUrlProvider, IOptions options, UAuthClientDiagnostics diagnostics) { _post = post; _events = events; + _clientDeviceProvider = clientDeviceProvider; _deviceIdProvider = deviceIdProvider; _returnUrlProvider = returnUrlProvider; _options = options.Value; @@ -41,30 +43,58 @@ public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IDev public async Task LoginAsync(LoginRequest request, string? returnUrl = null) { - var canPost = ClientLoginCapabilities.CanPostCredentials(_options.ClientProfile); + EnsureCanPost(); - 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."); - } + var payload = BuildPayload(request, returnUrl); - var resolvedReturnUrl = - returnUrl - ?? _options.Login.ReturnUrl - ?? _options.DefaultReturnUrl; + var url = Url(_options.Endpoints.Login); + await _post.NavigateAsync(url, payload); + } - var payload = request.ToDictionary(); + public async Task TryLoginAsync(LoginRequest request, UAuthSubmitMode mode, string? returnUrl = null) + { + EnsureCanPost(); - if (!string.IsNullOrWhiteSpace(resolvedReturnUrl)) + var payload = BuildPayload(request, returnUrl); + + var tryUrl = Url(_options.Endpoints.TryLogin); + var commitUrl = Url(_options.Endpoints.Login); + + switch (mode) { - payload["return_url"] = resolvedReturnUrl; - } + case UAuthSubmitMode.TryOnly: + { + var result = await _post.SendJsonAsync(tryUrl, request); - var url = Url(_options.Endpoints.Login); - await _post.NavigateAsync(url, payload); + if (result.Body is null) + throw new UAuthProtocolException("Empty response body."); + + var parsed = result.Body.Value.Deserialize( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + 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() @@ -173,7 +203,7 @@ public async Task ValidateAsync() public async Task BeginPkceAsync(string? returnUrl = null) { var pkce = _options.Pkce; - var deviceId = await _deviceIdProvider.GetOrCreateAsync(); + var device = await _clientDeviceProvider.GetAsync(); if (!pkce.Enabled) throw new InvalidOperationException("PKCE login is disabled by configuration."); @@ -188,8 +218,7 @@ public async Task BeginPkceAsync(string? returnUrl = null) new Dictionary { ["code_challenge"] = challenge, - ["challenge_method"] = "S256", - ["device_id"] = deviceId.Value + ["challenge_method"] = "S256" }); if (!raw.Ok || raw.Body is null) @@ -212,11 +241,72 @@ public async Task BeginPkceAsync(string? returnUrl = null) if (pkce.AutoRedirect) { - await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl, deviceId.Value); + 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(PkceLoginRequest request) + public async Task CompletePkceLoginAsync(PkceCompleteRequest request) { if (request is null) throw new ArgumentNullException(nameof(request)); @@ -237,6 +327,8 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) ["Identifier"] = request.Identifier ?? string.Empty, ["Secret"] = request.Secret ?? string.Empty, + + ["hub_session_id"] = request.HubSessionId ?? string.Empty, }; await _post.NavigateAsync(url, payload); @@ -296,17 +388,50 @@ public async Task LogoutAllDevicesAdminAsync(UserKey userKey) } - private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl, string deviceId) + 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_id"] = deviceId + ["device"] = deviceEncoded }; return _post.NavigateAsync(hubLoginUrl, data); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs index 0413196d..56003bfb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs @@ -111,7 +111,6 @@ await service.ChangeSecretAsync(context, var oldLogin = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "user" }); @@ -121,7 +120,6 @@ await service.ChangeSecretAsync(context, var newLogin = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "newpass123" }); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index 1d6b2e0e..4dda4404 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -31,7 +32,7 @@ public Task CompletePkceLoginAsync(LoginRequest request) throw new NotImplementedException(); } - public Task CompletePkceLoginAsync(PkceLoginRequest request) + public Task CompletePkceLoginAsync(PkceCompleteRequest request) { throw new NotImplementedException(); } @@ -119,6 +120,21 @@ public Task RefreshAsync(bool isAuto = false) }); } + 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/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index fd082291..38a62c52 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -88,7 +88,6 @@ public async Task LoginAsync(AuthFlowContext flow) return await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKeys.Single, Identifier = "user", Secret = "user" }); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs index 87daa2e2..5e4c9238 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs @@ -5,4 +5,5 @@ 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/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/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/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index bff92f6d..8aeb4392 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -4,6 +4,10 @@ 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; @@ -22,7 +26,6 @@ public async Task Successful_login_should_return_success_result() var result = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "user", }); @@ -40,7 +43,6 @@ public async Task Successful_login_should_create_session() var result = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "user", }); @@ -62,7 +64,6 @@ public async Task First_failed_login_should_record_attempt() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -87,7 +88,6 @@ public async Task Successful_login_should_clear_failure_state() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -95,7 +95,6 @@ await orchestrator.LoginAsync(flow, await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "user", // valid password }); @@ -116,7 +115,6 @@ public async Task Invalid_password_should_fail_login() var result = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -134,7 +132,6 @@ public async Task Non_existent_user_should_fail_login_gracefully() var result = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "ghost", Secret = "whatever", }); @@ -156,7 +153,6 @@ public async Task MaxFailedAttempts_one_should_lock_user_on_first_fail() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -182,7 +178,6 @@ public async Task Locked_user_should_not_login_even_with_correct_password() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -190,7 +185,6 @@ await orchestrator.LoginAsync(flow, var result = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "user", }); @@ -212,7 +206,6 @@ public async Task Locked_user_should_not_increment_failed_attempts() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -224,7 +217,6 @@ await orchestrator.LoginAsync(flow, await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", }); @@ -248,7 +240,6 @@ public async Task MaxFailedAttempts_zero_should_disable_lockout() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -277,7 +268,6 @@ public async Task Locked_user_failed_login_should_not_extend_lockout_duration() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -291,7 +281,6 @@ await orchestrator.LoginAsync(flow, await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", }); @@ -319,7 +308,6 @@ public async Task Login_success_should_trigger_UserLoggedIn_event() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "user", }); @@ -347,7 +335,6 @@ public async Task Login_success_should_trigger_OnAnyEvent() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "user", }); @@ -368,11 +355,81 @@ public async Task Event_handler_exception_should_not_break_login_flow() var result = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, 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 index cd72bded..76c39738 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -48,13 +48,13 @@ public void LoginFlow_Uses_Configured_Redirect_Options() } [Fact] - public void ClientProfile_Is_Read_From_Header() + 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 = reader.Read(ctx); + var profile = await reader.ReadAsync(ctx); profile.Should().Be(UAuthClientProfile.BlazorServer); } 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 index 404a9fc2..0011259e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs @@ -42,7 +42,6 @@ public async Task Logout_device_should_revoke_sessions_but_keep_chain() var result = await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "user", Secret = "user" }); @@ -96,7 +95,6 @@ public async Task Get_chain_detail_should_return_sessions() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKeys.Single, Identifier = "user", Secret = "user" }); @@ -121,7 +119,6 @@ public async Task Revoke_chain_should_revoke_all_sessions() await orchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKeys.Single, Identifier = "user", Secret = "user" }); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index 277d6922..049eeefc 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -66,10 +66,8 @@ await identifierService.AddUserIdentifierAsync(context, var result = await loginOrchestrator.LoginAsync(flow, new LoginRequest { - Tenant = TenantKey.Single, Identifier = "+905551111111", Secret = "user", - //Device = TestDevice.Default() }); result.IsSuccess.Should().BeFalse(); From 665ca4ab2a595ed187bbdb9cd27da5c4d8a6d8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:50:02 +0300 Subject: [PATCH 39/50] ResourceApi Infrastructure & Sample Improvement (#26) * ResourceApi Infrastructure & Sample Improvement * Cleanup Not Needed ResourceApi Definitions * Completed ResourceApi Infrastructure & Sample * Added Integration Test Project & First Login Tests --- UltimateAuth.slnx | 1 + .../Dialogs/ResourceApiDialog.razor | 54 +++++ .../Dialogs/ResourceApiDialog.razor.cs | 95 +++++++++ .../Pages/Home.razor | 9 + .../Pages/Home.razor.cs | 5 + .../Program.cs | 14 +- .../ResourceApi/ProductApiService.cs | 78 +++++++ .../ResourceApi/SampleProduct.cs | 7 + .../wwwroot/index.html | 2 - .../Controllers/WeatherForecastController.cs | 26 --- .../Program.cs | 53 +---- .../SampleData/AppActions.cs | 18 ++ .../SampleData/ProductStore.cs | 6 + .../SampleData/ProductsController.cs | 73 +++++++ .../SampleData/SampleProduct.cs | 7 + .../WeatherForecast.cs | 13 -- .../Contracts/Authority/AuthOperation.cs | 1 + .../Contracts/Common/UAuthResult.cs | 4 +- .../Contracts/Session/Dtos/AuthSnapshotDto.cs | 8 + .../Contracts/Session/Dtos/ClaimsDto.cs | 10 + .../Session/Dtos/SessionIdentityDto.cs | 11 + .../Session/Dtos/SessionValidationDto.cs | 10 + .../Session/SessionValidationResult.cs | 4 +- .../Defaults/UAuthConstants.cs | 1 + .../UAuthChallengeRequiredException.cs | 12 +- .../Runtime/UAuthAuthenticationException.cs | 12 ++ .../Infrastructure/SessionValidationMapper.cs | 92 ++++++++ .../Auth/Context/AuthFlowContextFactory.cs | 1 + .../UAuthAuthenticationExtension.cs | 7 + .../UAuthResourceAuthenticationHandler.cs | 78 +++++++ .../ResourceAccessContextBuilder.cs | 36 ++++ .../AspNetCore/UAuthActionRequirement.cs | 13 ++ .../AspNetCore/UAuthAuthorizationHandler.cs | 43 ++++ .../AspNetCore/UAuthPolicyProvider.cs | 29 +++ .../UAuthResourceAccessOrchestrator.cs | 77 +++++++ .../Extensions/AuthFlowTypeExtensions.cs | 3 +- .../HttpContextDeviceExtensions.cs} | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 201 +++++++++++------- .../UAuthApplicationBuilderExtensions.cs | 25 +++ .../UAuthExceptionHandlingExtensions.cs | 3 + .../Orchestrator/UAuthSessionOrchestrator.cs | 2 +- .../Session/SessionValidationMapper.cs | 34 --- .../User/HttpContextCurrentUser.cs | 1 - .../SessionValidationMiddleware.cs | 54 +++++ ...HubServerOptions.cs => UAuthHubOptions.cs} | 4 +- .../Options/UAuthResourceApiOptions.cs | 8 + .../Options/UAuthServerOptions.cs | 2 +- .../AllowAllAccessPolicyProvider.cs | 33 --- .../ResourceApi/NoOpIdentifierValidator.cs | 13 -- .../ResourceApi/NoOpRefreshTokenValidator.cs | 15 -- .../ResourceApi/NoOpSessionValidator.cs | 14 -- .../ResourceApi/NoOpTokenHasher.cs | 9 - .../ResourceApi/NoOpUserClaimsProvider.cs | 17 -- .../ResourceApi/NotSupportedPasswordHasher.cs | 16 -- .../NotSupportedRefreshTokenStoreFactory.cs | 12 -- .../NotSupportedSessionStoreFactory.cs | 12 -- .../NotSupportedUserRoleStoreFactory.cs | 12 -- .../ResourceApi/RemoteSessionValidator.cs | 50 +++++ .../ResourceApi/ResourceAuthContextFactory.cs | 61 ++++++ .../ResourceApi/ResourceUserAccessor.cs | 33 +++ .../Services/UAuthSessionValidator.cs | 2 + .../Defaults/DefaultPolicySet.cs | 17 +- .../Policies/RequireAuthenticatedPolicy.cs | 2 - .../AssemblyBehavior.cs | 1 + .../AuthServerFactory.cs | 12 ++ .../AuthServerTests.cs | 14 ++ ...Beam.UltimateAuth.Tests.Integration.csproj | 27 +++ .../LoginTests.cs | 96 +++++++++ .../AssemblyBehavior.cs | 1 + .../AssemblyVisibility.cs | 3 - 70 files changed, 1346 insertions(+), 375 deletions(-) create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/ProductApiService.cs create mode 100644 samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/SampleProduct.cs delete mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Controllers/WeatherForecastController.cs create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/AppActions.cs create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductStore.cs create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductsController.cs create mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/SampleProduct.cs delete mode 100644 samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/WeatherForecast.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthenticationException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthActionRequirement.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs rename src/CodeBeam.UltimateAuth.Server/Extensions/{DeviceExtensions.cs => HttpContext/HttpContextDeviceExtensions.cs} (90%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs rename src/CodeBeam.UltimateAuth.Server/Options/{UAuthHubServerOptions.cs => UAuthHubOptions.cs} (87%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceUserAccessor.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs delete mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 901d2484..be71aa02 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -16,6 +16,7 @@ + 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/Pages/Home.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor index 984e3912..beac4f94 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -142,6 +142,15 @@ } + + + Resource Api + + + + + Manage Resource + 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 index f734b4b8..9ee407b9 100644 --- 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 @@ -190,6 +190,11 @@ 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 diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 53a18e6b..7bc8536b 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Extensions; 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; @@ -28,16 +29,21 @@ }); builder.Services.AddMudExtensions(); +builder.Services.AddScoped(); builder.Services.AddScoped(); -//builder.Services.AddHttpClient("UAuthHub", client => -//{ -// client.BaseAddress = new Uri("https://localhost:6110"); -//}); //builder.Services.AddHttpClient("ResourceApi", client => //{ // client.BaseAddress = new Uri("https://localhost:6120"); //}); +builder.Services.AddScoped(sp => +{ + return new HttpClient + { + BaseAddress = new Uri("https://localhost:6120") // Resource API + }; +}); + await builder.Build().RunAsync(); 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/wwwroot/index.html b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html index 6499fa41..26cd7ae0 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html @@ -8,8 +8,6 @@ - - diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Controllers/WeatherForecastController.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Controllers/WeatherForecastController.cs deleted file mode 100644 index 1e76b826..00000000 --- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace CodeBeam.UltimateAuth.Sample.ResourceApi.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase - { - private static readonly string[] Summaries = - [ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - ]; - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } - } -} diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs index ebb830f7..35eb05d1 100644 --- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs @@ -1,74 +1,29 @@ -using System.Security.Claims; -using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Server.Extensions; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. - builder.Services.AddControllers(); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); -builder.Services.AddUltimateAuthResourceApi(); - -builder.Services.AddCors(options => -{ - options.AddPolicy("WasmSample", policy => +builder.Services.AddUltimateAuthResourceApi(o => { - policy - .WithOrigins("https://localhost:6130") - .AllowAnyHeader() - .AllowAnyMethod(); + o.UAuthHubBaseUrl = "https://localhost:6110"; + o.AllowedClientOrigins.Add("https://localhost:6130"); }); -}); var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } app.UseHttpsRedirection(); -app.UseCors("WasmSample"); +app.UseUltimateAuthResourceApi(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); -app.MapGet("/health", () => -{ - return Results.Ok(new - { - service = "ResourceApi", - status = "ok" - }); -}); - -app.MapGet("/me", (ClaimsPrincipal user) => -{ - return Results.Ok(new - { - IsAuthenticated = user.Identity?.IsAuthenticated, - Name = user.Identity?.Name, - Claims = user.Claims.Select(c => new - { - c.Type, - c.Value - }) - }); -}) -.RequireAuthorization(); - -app.MapGet("/data", () => -{ - return Results.Ok(new - { - Message = "You are authorized to access protected data." - }); -}) -.RequireAuthorization("ApiUser"); app.Run(); 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/WeatherForecast.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/WeatherForecast.cs deleted file mode 100644 index fc51bad6..00000000 --- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/WeatherForecast.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace CodeBeam.UltimateAuth.Sample.ResourceApi -{ - public class WeatherForecast - { - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs index 9f886535..1671ed53 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs @@ -4,6 +4,7 @@ public enum AuthOperation { Login, Access, + ResourceAccess, Refresh, Revoke, Logout, diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index 0ca7c003..2a674d30 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -7,7 +7,7 @@ public class UAuthResult public string? CorrelationId { get; init; } public string? TraceId { get; init; } - public UAuthProblem? Problem { get; init; } + public UAuthProblem? Problem { get; set; } public HttpStatusInfo Http => new(Status); @@ -31,5 +31,5 @@ internal HttpStatusInfo(int status) public sealed class UAuthResult : UAuthResult { - public T? Value { get; init; } + public T? Value { get; set; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs new file mode 100644 index 00000000..e4d4f0cc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class AuthSnapshotDto +{ + public IdentityDto? Identity { get; set; } + + public ClaimsDto? Claims { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs new file mode 100644 index 00000000..76482332 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class ClaimsDto +{ + 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/SessionIdentityDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs new file mode 100644 index 00000000..e962875e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class IdentityDto +{ + 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/SessionValidationDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs new file mode 100644 index 00000000..353b8de5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class SessionValidationDto +{ + public int State { get; set; } = default!; + + public bool IsValid { get; set; } + + public AuthSnapshotDto? Snapshot { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs index bb4a0ac8..9443abab 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -29,7 +29,7 @@ private SessionValidationResult() { } public static SessionValidationResult Active( TenantKey tenant, - UserKey? userId, + UserKey? userKey, AuthSessionId sessionId, SessionChainId chainId, SessionRootId rootId, @@ -40,7 +40,7 @@ public static SessionValidationResult Active( { Tenant = tenant, State = SessionState.Active, - UserKey = userId, + UserKey = userKey, SessionId = sessionId, ChainId = chainId, RootId = rootId, diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs index 0a9418b5..c6761ad6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs @@ -21,6 +21,7 @@ public static class Claims 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"; } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs index 6aaae599..5eed57ee 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs @@ -1,9 +1,15 @@ namespace CodeBeam.UltimateAuth.Core.Errors; -public sealed class UAuthChallengeRequiredException : UAuthException +public sealed class UAuthChallengeRequiredException : UAuthRuntimeException { - public UAuthChallengeRequiredException(string? reason = null) - : base(code: "challenge_required", message: reason ?? "Additional authentication is required.") + 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/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/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs new file mode 100644 index 00000000..f605ed94 --- /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(SessionValidationDto 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.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index 6cb5db77..3357fd43 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -1,6 +1,7 @@ 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; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs index eabcc607..e2b4ed54 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs @@ -13,4 +13,11 @@ public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder b 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/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/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/Extensions/AuthFlowTypeExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs index a61260b8..953ac704 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs @@ -11,7 +11,6 @@ public static AuthOperation ToAuthOperation(this AuthFlowType flowType) AuthFlowType.Login => AuthOperation.Login, AuthFlowType.Reauthentication => AuthOperation.Login, - AuthFlowType.ApiAccess => AuthOperation.Access, AuthFlowType.ValidateSession => AuthOperation.Access, AuthFlowType.UserInfo => AuthOperation.Access, AuthFlowType.PermissionQuery => AuthOperation.Access, @@ -25,6 +24,8 @@ public static AuthOperation ToAuthOperation(this AuthFlowType flowType) 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/DeviceExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextDeviceExtensions.cs similarity index 90% rename from src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextDeviceExtensions.cs index 5a5ec37a..3b93237f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/DeviceExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextDeviceExtensions.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; -public static class DeviceExtensions +public static class HttpContextDeviceExtensions { public static async Task GetDeviceAsync(this HttpContext context) { diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 589f059f..6c6977dd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ 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; @@ -8,7 +9,6 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; -using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; @@ -17,29 +17,31 @@ 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; -using CodeBeam.UltimateAuth.Users; -using CodeBeam.UltimateAuth.Server.ResourceApi; namespace CodeBeam.UltimateAuth.Server.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action? configure = null) + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action? configure = null, Action? configurePolicies = null) { ArgumentNullException.ThrowIfNull(services); services.AddUltimateAuth(); @@ -47,7 +49,8 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s AddUsersInternal(services); AddCredentialsInternal(services); AddAuthorizationInternal(services); - AddUltimateAuthPolicies(services); + + services.AddUltimateAuthPolicies(configurePolicies); services.AddOptions() // Program.cs configuration (lowest precedence) @@ -67,29 +70,39 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s return services; } - public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollection services, Action? configure = null) + public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollection services, Action? configure = null, Action? configurePolicies = null) { ArgumentNullException.ThrowIfNull(services); services.AddUltimateAuth(); + services.AddUltimateAuthPolicies(configurePolicies, isResourceApp: true); - //AddUsersInternal(services); - //AddCredentialsInternal(services); - //AddAuthorizationInternal(services); - //AddUltimateAuthPolicies(services); - - services.AddOptions() + services.AddOptions() .Configure(options => { configure?.Invoke(options); }) - .BindConfiguration("UltimateAuth:Server") - .PostConfigure(options => - { - options.Endpoints.Authentication = false; - }); + .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; } @@ -126,7 +139,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); - // EVENTS + // Events services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; @@ -178,7 +191,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.AddHttpContextAccessor(); - services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + services.TryAddScoped, UAuthUserAccessor>(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -229,7 +242,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -247,9 +260,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); - // ----------------------------- - // ENDPOINTS - // ----------------------------- + // Endpoints services.TryAddScoped(); services.TryAddSingleton(); @@ -260,9 +271,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); - // ------------------------------ - // ASP.NET CORE INTEGRATION - // ------------------------------ + // ASP.NET Core Integration services.AddAuthentication(); services.PostConfigureAll(options => @@ -273,10 +282,10 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol }); services.AddAuthentication().AddUAuthCookies(); - - services.AddAuthorization(); + services.AddSingleton(); + services.AddScoped(); services.Configure(opt => { @@ -293,14 +302,22 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol return services; } - internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) + 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(); - DefaultPolicySet.Register(registry); + if (isResourceApp) + { + DefaultPolicySet.RegisterResource(registry); + } + else + { + DefaultPolicySet.RegisterServer(registry); + } + configure?.Invoke(registry); services.AddSingleton(registry); @@ -352,50 +369,7 @@ internal static IServiceCollection AddAuthorizationInternal(IServiceCollection s return services; } - // TODO: This is not true, need to build true pipeline for ResourceApi. - private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceCollection services) - { - services.AddSingleton(); - - services.TryAddScoped(); - services.TryAddScoped(); - - services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - - services.TryAddScoped(); - services.TryAddScoped(); - - services.TryAddScoped(); - - services.TryAddSingleton(); - - services.AddHttpContextAccessor(); - services.AddAuthentication(); - - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddScoped(); - services.TryAddSingleton(); - - services.Replace(ServiceDescriptor.Scoped()); - services.Replace(ServiceDescriptor.Scoped()); - - services.PostConfigureAll(options => - { - options.DefaultAuthenticateScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; - }); - - return services; - } - - public static IServiceCollection AddUAuthHub(this IServiceCollection services, Action? configure = null) + public static IServiceCollection AddUAuthHub(this IServiceCollection services, Action? configure = null) { services.PostConfigure(options => { @@ -433,6 +407,85 @@ public static IServiceCollection AddUAuthHub(this IServiceCollection services, A 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 diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index 23e99b20..c78d6427 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -1,8 +1,10 @@ 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; @@ -40,4 +42,27 @@ public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicatio 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; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs index cf9b5520..6dd8d03a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -46,11 +46,14 @@ private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeExcepti 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/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs index 190cb521..502f7ed6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -28,7 +28,7 @@ public async Task ExecuteAsync(AuthContext authContext, ISessi switch (decision.Decision) { case AuthorizationDecision.Deny: - throw new UAuthAuthorizationException(decision.Reason ?? "authorization_denied"); + throw new UAuthAuthenticationException(decision.Reason ?? "authorization_denied"); case AuthorizationDecision.Challenge: throw new UAuthChallengeRequiredException(decision.Reason); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs deleted file mode 100644 index eb649433..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionValidationMapper.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -internal static class SessionValidationMapper -{ - 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.Server/Infrastructure/User/HttpContextCurrentUser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs index 64664ca5..7dd0f3a0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs @@ -2,7 +2,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Infrastructure; 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/Options/UAuthHubServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs similarity index 87% rename from src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs index 96b6f414..c2f060d8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class UAuthHubServerOptions +public sealed class UAuthHubOptions { public string? ClientBaseAddress { get; set; } @@ -14,7 +14,7 @@ public sealed class UAuthHubServerOptions public string? LoginPath { get; set; } = "/login"; - internal UAuthHubServerOptions Clone() => new() + internal UAuthHubOptions Clone() => new() { ClientBaseAddress = ClientBaseAddress, AllowedClientOrigins = new HashSet(AllowedClientOrigins), 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/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 34f41c83..6706f0d4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -76,7 +76,7 @@ public sealed class UAuthServerOptions public UAuthResetOptions ResetCredential { get; init; } = new(); - public UAuthHubServerOptions Hub { get; set; } = new(); + public UAuthHubOptions Hub { get; set; } = new(); /// /// Controls how session identifiers are resolved from incoming requests diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs deleted file mode 100644 index b6f55185..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/AllowAllAccessPolicyProvider.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Policies.Abstractions; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi -{ - internal sealed class AllowAllAccessPolicyProvider : IAccessPolicyProvider - { - public IReadOnlyCollection GetPolicies(AccessContext context) - { - throw new NotSupportedException(); - } - - public Task ResolveAsync(string name, CancellationToken ct = default) - => Task.FromResult(new AllowAllPolicy()); - } - - internal sealed class AllowAllPolicy : IAccessPolicy - { - public bool AppliesTo(AccessContext context) - { - throw new NotImplementedException(); - } - - public AccessDecision Decide(AccessContext context) - { - throw new NotImplementedException(); - } - - public Task EvaluateAsync(AccessContext context, CancellationToken ct = default) - => Task.FromResult(true); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs deleted file mode 100644 index de7ff5df..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpIdentifierValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal class NoOpIdentifierValidator : IIdentifierValidator -{ - public Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default) - { - throw new NotImplementedException(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs deleted file mode 100644 index 9b173787..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpRefreshTokenValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal sealed class NoOpRefreshTokenValidator : IRefreshTokenValidator -{ - public Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default) - => Task.CompletedTask; - - Task IRefreshTokenValidator.ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct) - { - throw new NotImplementedException(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs deleted file mode 100644 index a6b54a4a..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpSessionValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal sealed class NoOpSessionValidator : ISessionValidator -{ - public Task ValidateSesAsync(SessionValidationContext context, CancellationToken ct = default) - => Task.CompletedTask; - - public Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) - { - throw new NotSupportedException(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs deleted file mode 100644 index a1f51a9f..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpTokenHasher.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal sealed class NoOpTokenHasher : ITokenHasher -{ - public string Hash(string input) => input; - public bool Verify(string input, string hash) => input == hash; -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs deleted file mode 100644 index 4d7e0d92..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NoOpUserClaimsProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using System.Security.Claims; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal sealed class NoOpUserClaimsProvider : IUserClaimsProvider -{ - public Task> GetClaimsAsync(TenantKey tenant, UserKey user, CancellationToken ct = default) - => Task.FromResult>(Array.Empty()); - - Task IUserClaimsProvider.GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) - { - return Task.FromResult(ClaimsSnapshot.Empty); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs deleted file mode 100644 index b2f94991..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedPasswordHasher.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal class NotSupportedPasswordHasher : IUAuthPasswordHasher -{ - public string Hash(string password) - { - throw new NotSupportedException(); - } - - public bool Verify(string hash, string secret) - { - throw new NotSupportedException(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs deleted file mode 100644 index 1de3a14d..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedRefreshTokenStoreFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal class NotSupportedRefreshTokenStoreFactory : IRefreshTokenStoreFactory -{ - public IRefreshTokenStore Create(TenantKey tenant) - { - throw new NotSupportedException(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs deleted file mode 100644 index 3f5db1ca..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedSessionStoreFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal class NotSupportedSessionStoreFactory : ISessionStoreFactory -{ - public ISessionStore Create(TenantKey tenant) - { - throw new NotSupportedException(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs deleted file mode 100644 index 33b75cdb..00000000 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/NotSupportedUserRoleStoreFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Server.ResourceApi; - -internal class NotSupportedUserRoleStoreFactory : IUserRoleStoreFactory -{ - public IUserRoleStore Create(TenantKey tenant) - { - throw new NotSupportedException(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs new file mode 100644 index 00000000..5bfe197b --- /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/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index da89b7c9..787e5281 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -22,6 +22,8 @@ public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvi // 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); diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs index c7ccf270..bf91cb87 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Policies.Defaults; internal static class DefaultPolicySet { - public static void Register(AccessPolicyRegistry registry) + public static void RegisterServer(AccessPolicyRegistry registry) { // Invariant registry.Add("", _ => new RequireAuthenticatedPolicy()); @@ -22,4 +22,19 @@ public static void Register(AccessPolicyRegistry registry) // 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/Policies/RequireAuthenticatedPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs index cbe238d4..fb26bb6a 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs @@ -1,7 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Defaults; -using System.Net; namespace CodeBeam.UltimateAuth.Policies; 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/AssemblyVisibility.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs deleted file mode 100644 index f9e4007a..00000000 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyVisibility.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: CollectionBehavior(DisableTestParallelization = true)] From a5e838e67eb3a95a4939038c56ea3fee8316e4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:57:11 +0300 Subject: [PATCH 40/50] Add EFCore Sample (#27) * Added Some Client Tests * Created Blacor Server EFCore Sample * Session Store Fix * Added Facade UAuthDbContext * Fix CORS & Completed Samples * Update README --- README.md | 196 +++-- UltimateAuth.slnx | 4 +- ...ateAuth.EntityFrameworkCore.Bundle.csproj} | 0 .../Data/UAuthDbContext.cs | 53 ++ ...timateAuthEntityFrameworkCoreExtensions.cs | 42 +- .../{ => Options}/UAuthEfCoreOptions.cs | 0 .../AuthorizationSeedContributor.cs | 14 +- .../CodeBeam.UltimateAuth.Sample.Seed.csproj | 13 + .../CredentialSeedContributor.cs | 15 +- .../Extensions/ServiceCollectionExtensions.cs | 32 + .../IUserIdProvider.cs | 7 + .../UserIdProvider.cs | 5 +- .../UserSeedContributor.cs | 16 +- ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 1 + .../Program.cs | 3 + .../Brand/UAuthLogo.razor | 19 + .../Brand/UAuthLogo.razor.cs | 54 ++ .../Brand/UAuthLogoVariant.cs | 7 + ...mateAuth.Sample.BlazorServer.EFCore.csproj | 33 + .../Common/UAuthDialog.cs | 29 + .../Components/App.razor | 29 + .../Custom/UAuthPageComponent.razor | 10 + .../Dialogs/AccountStatusDialog.razor | 23 + .../Dialogs/AccountStatusDialog.razor.cs | 77 ++ .../Components/Dialogs/CreateUserDialog.razor | 27 + .../Dialogs/CreateUserDialog.razor.cs | 55 ++ .../Components/Dialogs/CredentialDialog.razor | 51 ++ .../Dialogs/CredentialDialog.razor.cs | 92 +++ .../Components/Dialogs/IdentifierDialog.razor | 106 +++ .../Dialogs/IdentifierDialog.razor.cs | 309 ++++++++ .../Components/Dialogs/PermissionDialog.razor | 46 ++ .../Dialogs/PermissionDialog.razor.cs | 119 +++ .../Components/Dialogs/ProfileDialog.razor | 94 +++ .../Components/Dialogs/ProfileDialog.razor.cs | 114 +++ .../Components/Dialogs/ResetDialog.razor | 38 + .../Components/Dialogs/ResetDialog.razor.cs | 42 ++ .../Components/Dialogs/RoleDialog.razor | 81 ++ .../Components/Dialogs/RoleDialog.razor.cs | 163 ++++ .../Components/Dialogs/SessionDialog.razor | 217 ++++++ .../Components/Dialogs/SessionDialog.razor.cs | 284 +++++++ .../Components/Dialogs/UserDetailDialog.razor | 75 ++ .../Dialogs/UserDetailDialog.razor.cs | 100 +++ .../Components/Dialogs/UserRoleDialog.razor | 49 ++ .../Dialogs/UserRoleDialog.razor.cs | 112 +++ .../Components/Dialogs/UsersDialog.razor | 85 +++ .../Components/Dialogs/UsersDialog.razor.cs | 176 +++++ .../Components/Layout/MainLayout.razor | 65 ++ .../Components/Layout/MainLayout.razor.cs | 130 ++++ .../Components/Layout/MainLayout.razor.css | 18 + .../Components/Layout/ReconnectModal.razor | 31 + .../Layout/ReconnectModal.razor.css | 157 ++++ .../Components/Layout/ReconnectModal.razor.js | 63 ++ .../Components/Pages/AnonymousTestPage.razor | 1 + .../Components/Pages/AuthorizedTestPage.razor | 26 + .../Components/Pages/Error.razor | 36 + .../Components/Pages/Home.razor | 444 +++++++++++ .../Components/Pages/Home.razor.cs | 222 ++++++ .../Components/Pages/LandingPage.razor | 4 + .../Components/Pages/LandingPage.razor.cs | 17 + .../Components/Pages/Login.razor | 126 ++++ .../Components/Pages/Login.razor.cs | 211 ++++++ .../Components/Pages/NotAuthorized.razor | 27 + .../Components/Pages/NotAuthorized.razor.cs | 15 + .../Components/Pages/Register.razor | 60 ++ .../Components/Pages/Register.razor.cs | 45 ++ .../Components/Pages/ResetCredential.razor | 18 + .../Components/Pages/ResetCredential.razor.cs | 49 ++ .../Components/Routes.razor | 73 ++ .../Components/_Imports.razor | 23 + .../Infrastructure/DarkModeManager.cs | 45 ++ ...0260327184128_InitUltimateAuth.Designer.cs | 710 ++++++++++++++++++ .../20260327184128_InitUltimateAuth.cs | 556 ++++++++++++++ .../Migrations/UAuthDbContextModelSnapshot.cs | 707 +++++++++++++++++ .../Program.cs | 111 +++ .../Properties/launchSettings.json | 23 + .../Seed/UAuthDbInitializer.cs | 46 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../uauth.db | Bin 0 -> 4096 bytes .../uauth.db-shm | Bin 0 -> 32768 bytes .../uauth.db-wal | Bin 0 -> 716912 bytes .../wwwroot/UltimateAuth-Logo.png | Bin 0 -> 14776 bytes .../wwwroot/app.css | 143 ++++ ...Beam.UltimateAuth.Sample.BlazorServer.slnx | 3 - ...am.UltimateAuth.Sample.BlazorServer.csproj | 2 +- .../Components/Pages/Register.razor | 6 + .../Program.cs | 6 +- .../Pages/Register.razor | 6 + .../Program.cs | 25 +- .../Program.cs | 6 +- .../EndpointRouteBuilderExtensions.cs | 22 +- .../Extensions/ServiceCollectionExtensions.cs | 2 - .../UAuthApplicationBuilderExtensions.cs | 9 + .../Issuers/UAuthSessionIssuer.cs | 26 +- .../Data/UAuthAuthenticationDbContext.cs | 67 +- .../Data/UAuthAuthenticationModelBuilder.cs | 75 ++ .../Extensions/ServiceCollectionExtensions.cs | 10 +- .../AuthenticationSecutiryStateProjection.cs | 2 +- .../EfCoreAuthenticationSecurityStateStore.cs | 18 +- ...AuthenticationSecurityStateStoreFactory.cs | 9 +- .../Data/UAuthAuthorizationDbContext.cs | 120 +-- .../Data/UAuthAuthorizationModelBuilder.cs | 109 +++ .../Extensions/ServiceCollectionExtensions.cs | 12 +- .../Projections/RolePermissionProjection.cs | 2 +- .../Projections/RoleProjection.cs | 4 +- .../Projections/UserRoleProjection.cs | 2 +- .../Stores/EfCoreRoleStore.cs | 47 +- .../Stores/EfCoreRoleStoreFactory.cs | 9 +- .../Stores/EfCoreUserRoleStore.cs | 22 +- .../Stores/EfCoreUserRoleStoreFactory.cs | 9 +- .../Extensions/ServiceCollectionExtensions.cs | 3 - .../IAuthorizationSeeder.cs | 6 - .../Services/UAuthFlowClient.cs | 19 +- .../Data/UAuthCredentialDbContext.cs | 56 +- .../Data/UAuthCredentialsModelBuilder.cs | 60 ++ .../Extensions/ServiceCollectionExtensions.cs | 10 +- .../PasswordCredentialProjection.cs | 2 +- .../Stores/EfCorePasswordCredentialStore.cs | 28 +- .../EfCorePasswordCredentialStoreFactory.cs | 9 +- .../ServiceCollectionExtensions.cs | 3 - .../Infrastructure/DateTimeOffsetConverter.cs | 31 + .../Data/UAuthSessionDbContext.cs | 163 +--- .../Data/UAuthSessionsModelBuilder.cs | 170 +++++ .../Extensions/ServiceCollectionExtensions.cs | 10 +- .../Projections/SessionChainProjection.cs | 2 +- .../Projections/SessionProjection.cs | 5 +- .../Projections/SessionRootProjection.cs | 2 +- .../Stores/EfCoreSessionStore.cs | 129 ++-- .../Stores/EfCoreSessionStoreFactory.cs | 9 +- .../Data/UAuthTokenDbContext.cs | 60 +- .../Data/UAuthTokenModelBuilder.cs | 73 ++ .../Extensions/ServiceCollectionExtensions.cs | 10 +- .../Projections/RefreshTokenProjection.cs | 4 +- .../Stores/EfCoreRefreshTokenStore.cs | 20 +- .../Stores/EfCoreRefreshTokenStoreFactory.cs | 9 +- .../Data/UAuthUserDbContext.cs | 129 +--- .../Data/UAuthUsersModelBuilder.cs | 122 +++ .../Extensions/ServiceCollectionExtensions.cs | 14 +- .../Projections/UserIdentifierProjections.cs | 2 +- .../Projections/UserLifecycleProjection.cs | 2 +- .../Projections/UserProfileProjection.cs | 4 +- .../Stores/EFCoreUserProfileStoreFactory.cs | 9 +- .../Stores/EfCoreUserIdentifierStore.cs | 40 +- .../EfCoreUserIdentifierStoreFactory.cs | 8 +- .../Stores/EfCoreUserLifecycleStore.cs | 22 +- .../Stores/EfCoreUserLifecycleStoreFactory.cs | 8 +- .../Stores/EfCoreUserProfileStore.cs | 24 +- .../Extensions/ServiceCollectionExtensions.cs | 4 - .../Client/AuthStateSnapshotFactoryTests.cs | 5 +- .../Client/UAuthFlowClientTests.cs | 295 ++++++++ .../Client/UAuthResultMapperTests.cs | 136 ++++ .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 3 +- .../EfCoreAuthenticationStoreTests.cs | 30 +- .../EfCoreCredentialStoreTests.cs | 32 +- .../EfCoreRoleStoreTests.cs | 32 +- .../EfCoreSessionStoreTests.cs | 60 +- .../EfCoreTokenStoreTests.cs | 10 +- .../EfCoreUserIdentifierStoreTests.cs | 20 +- .../EfCoreUserLifecycleStoreTests.cs | 26 +- .../EfCoreUserProfileStoreTests.cs | 20 +- .../EfCoreUserRoleStoreTests.cs | 16 +- .../Helpers/TestAuthRuntime.cs | 3 + .../Helpers/TestDto.cs | 6 + 163 files changed, 8857 insertions(+), 1084 deletions(-) rename nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/{CodeBeam.UltimateAuth.EntityFrameworkCoreReference.csproj => CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle.csproj} (100%) create mode 100644 nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Data/UAuthDbContext.cs rename nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/{ => Extensions}/UltimateAuthEntityFrameworkCoreExtensions.cs (68%) rename nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/{ => Options}/UAuthEfCoreOptions.cs (100%) rename src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs => samples/CodeBeam.UltimateAuth.Sample.Seed/AuthorizationSeedContributor.cs (86%) create mode 100644 samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj rename src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs => samples/CodeBeam.UltimateAuth.Sample.Seed/CredentialSeedContributor.cs (78%) create mode 100644 samples/CodeBeam.UltimateAuth.Sample.Seed/Extensions/ServiceCollectionExtensions.cs create mode 100644 samples/CodeBeam.UltimateAuth.Sample.Seed/IUserIdProvider.cs rename src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs => samples/CodeBeam.UltimateAuth.Sample.Seed/UserIdProvider.cs (69%) rename src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs => samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs (88%) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogo.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogo.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogoVariant.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Common/UAuthDialog.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/App.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Custom/UAuthPageComponent.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/AccountStatusDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/AccountStatusDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CreateUserDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CreateUserDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/IdentifierDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/IdentifierDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/SessionDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/SessionDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserDetailDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserDetailDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor.css create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor.css create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor.js create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AnonymousTestPage.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Error.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/LandingPage.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/LandingPage.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/NotAuthorized.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/NotAuthorized.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Routes.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/_Imports.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Infrastructure/DarkModeManager.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.Designer.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Properties/launchSettings.json create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Seed/UAuthDbInitializer.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/appsettings.Development.json create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/appsettings.json create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/wwwroot/UltimateAuth-Logo.png create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/wwwroot/app.css delete mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.slnx create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationModelBuilder.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationModelBuilder.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/DateTimeOffsetConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionsModelBuilder.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenModelBuilder.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDto.cs diff --git a/README.md b/README.md index 69aad5de..d1c63fa8 100644 --- a/README.md +++ b/README.md @@ -16,38 +16,118 @@ The first preview release (**v 0.1.0-preview**) is planned within the next week. [![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 + +| Phase | Version | Scope | Status | Release Date | +| ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | +| First Preview | 0.1.0-preview | "Stable" Preview Core | ✅ Completed | Last check | +| 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 | + +*v 0.1.0 already provides a skeleton of multi tenancy, MFA, reauth etc. Expansion releases will enhance these areas. + +> The project roadmap is actively maintained as a GitHub issue: + +👉 https://github.com/CodeBeamOrg/UltimateAuth/issues/8 + +We keep it up-to-date with current priorities, planned features, and progress. Feel free to follow, comment, or contribute ideas. + +
+ +> 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 +
--- ## 🌟 Why UltimateAuth: The Six-Point Principles -### **1) Developer-Centric & User-Friendly** -Clean APIs, predictable behavior, minimal ceremony — designed to make authentication *pleasant* for developers. +### 1) Unified Authentication System + +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. -### **2) Security-Driven** -PKCE, hardened session flows, reuse detection, event-driven safeguards, device awareness, and modern best practices. +### 2) Plug & Play Ready -### **3) Extensible & Lightweight by Design** -Every component can be replaced or overridden. -No forced dependencies. No unnecessary weight. +Built-in capabilities designed for real-world scenarios: -### **4) Plug-and-Play Ready** -From setup to production, UltimateAuth prioritizes a frictionless integration journey with sensible defaults. +- 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 -### **5) 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. +No boilerplate. No hidden complexity. -### **6) Unified Framework** -One solution, same codebase across Blazor server, WASM and MAUI. UltimateAuth handles client differences internally and providing consistent and reliable public API. +### 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 + +Start simple, scale infinitely: + +- Works out of the box with sensible defaults +- Replace any component when needed +- No forced architecture decisions + +### 6) Built for Modern .NET Applications + +Designed specifically for real-world .NET environments: + +- Blazor Server +- Blazor WASM +- .NET MAUI +- Backend APIs + +Traditional auth solutions struggle here — UltimateAuth embraces it. --- # 🚀 Quick Start +> ⏱ Takes ~2 minutes to get started ### 1) Install packages (Will be available soon) @@ -66,7 +146,11 @@ Server registration: ```csharp builder.Services .AddUltimateAuthServer() - .AddUltimateAuthEntityFrameworkCore(); // Production + .AddUltimateAuthEntityFrameworkCore(db => + { + // use with your database provider + db.UseSqlite("Data Source=uauth.db"); + }); // OR @@ -107,7 +191,36 @@ Place this in `App.razor` or `index.html` ``` -### 5) Optional: Blazor Usings +### 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 @@ -181,39 +294,6 @@ UltimateAuth turns Auth into a simple application service — not a separate sys --- - -## 📅 Release Timeline (Targeted) - -> _Dates reflect targeted milestones and may evolve with community feedback._ - -### **Q1 2026 — First Release** -- v 0.1.0-preview to v 0.1.0 - -### **Q2 2026 — Stable Feature Releases** -- v 0.2.0 to v 0.3.0 - -### **Q3 2026 — General Availability** -- API surface locked -- Production-ready security hardening -- Unified architecture finalized - -### **Q4 2026 — v 11.x.x (.NET 11 Alignment Release)** -UltimateAuth adopts .NET platform versioning to align with the broader ecosystem. - ---- - -## 🗺 Roadmap - -The project roadmap is actively maintained as a GitHub issue: - -👉 https://github.com/CodeBeamOrg/UltimateAuth/issues/8 - -We keep it up-to-date with current priorities, planned features, and progress. - -Feel free to follow, comment, or contribute ideas. - ---- - ## 📘 Documentation Two documentation experiences will be provided: @@ -235,22 +315,6 @@ Discussions are open — your ideas matter. --- -## 🛠 Project Status - -UltimateAuth core architecture is implemented and validated through the sample application. - -We are currently: - -- Polishing developer experience -- Reviewing public APIs -- Preparing EF Core integration packages - -Preview release is coming soon. - -You can check the samples and try what UltimateAuth offers by downloading repo and running locally. - ---- - ## ⭐ Acknowledgements UltimateAuth is built with love by CodeBeam and shaped by real-world .NET development — diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index be71aa02..cba5a257 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,12 +1,14 @@ - + + + diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCoreReference.csproj b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle.csproj similarity index 100% rename from nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCoreReference.csproj rename to nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle.csproj diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Data/UAuthDbContext.cs b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Data/UAuthDbContext.cs new file mode 100644 index 00000000..35347918 --- /dev/null +++ b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Data/UAuthDbContext.cs @@ -0,0 +1,53 @@ +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class UAuthDbContext : DbContext +{ + public UAuthDbContext(DbContextOptions options) + : base(options) + { + } + + // Users + public DbSet UserLifecycles => Set(); + public DbSet UserProfiles => Set(); + public DbSet UserIdentifiers => Set(); + + // Credentials + public DbSet PasswordCredentials => Set(); + + // Authorization + public DbSet Roles => Set(); + public DbSet UserRoleAssignments => Set(); + public DbSet UserPermissions => Set(); + + // Sessions + public DbSet Roots => Set(); + public DbSet Chains => Set(); + public DbSet Sessions => Set(); + + // Tokens + public DbSet RefreshTokens => Set(); + + // Authentication + public DbSet AuthenticationSecurityStates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + UAuthSessionsModelBuilder.Configure(modelBuilder); + UAuthTokensModelBuilder.Configure(modelBuilder); + UAuthAuthenticationModelBuilder.Configure(modelBuilder); + UAuthUsersModelBuilder.Configure(modelBuilder); + UAuthCredentialsModelBuilder.Configure(modelBuilder); + UAuthAuthorizationModelBuilder.Configure(modelBuilder); + } +} diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UltimateAuthEntityFrameworkCoreExtensions.cs b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Extensions/UltimateAuthEntityFrameworkCoreExtensions.cs similarity index 68% rename from nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UltimateAuthEntityFrameworkCoreExtensions.cs rename to nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Extensions/UltimateAuthEntityFrameworkCoreExtensions.cs index f5efa97b..6d69104d 100644 --- a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UltimateAuthEntityFrameworkCoreExtensions.cs +++ b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Extensions/UltimateAuthEntityFrameworkCoreExtensions.cs @@ -1,9 +1,15 @@ -using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.Extensions; using CodeBeam.UltimateAuth.Reference.Bundle; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -56,14 +62,9 @@ public static class UltimateAuthEntityFrameworkCoreExtensions /// public static IServiceCollection AddUltimateAuthEntityFrameworkCore(this IServiceCollection services, Action configureDb) { - services - .AddUltimateAuthReferences() - .AddUltimateAuthUsersEntityFrameworkCore(configureDb) - .AddUltimateAuthCredentialsEntityFrameworkCore(configureDb) - .AddUltimateAuthAuthorizationEntityFrameworkCore(configureDb) - .AddUltimateAuthSessionsEntityFrameworkCore(configureDb) - .AddUltimateAuthTokensEntityFrameworkCore(configureDb) - .AddUltimateAuthAuthenticationEntityFrameworkCore(configureDb); + services.AddUltimateAuthReferences(); + services.AddDbContext(configureDb); + services.AddUltimateAuthEfCoreStores(); return services; } @@ -91,13 +92,24 @@ public static IServiceCollection AddUltimateAuthEntityFrameworkCore(this IServic services .AddUltimateAuthReferences() - .AddUltimateAuthUsersEntityFrameworkCore(options.Resolve(options.Users)) - .AddUltimateAuthCredentialsEntityFrameworkCore(options.Resolve(options.Credentials)) - .AddUltimateAuthAuthorizationEntityFrameworkCore(options.Resolve(options.Authorization)) - .AddUltimateAuthSessionsEntityFrameworkCore(options.Resolve(options.Sessions)) - .AddUltimateAuthTokensEntityFrameworkCore(options.Resolve(options.Tokens)) - .AddUltimateAuthAuthenticationEntityFrameworkCore(options.Resolve(options.Authentication)); + .AddUltimateAuthUsersEntityFrameworkCore(options.Resolve(options.Users)) + .AddUltimateAuthCredentialsEntityFrameworkCore(options.Resolve(options.Credentials)) + .AddUltimateAuthAuthorizationEntityFrameworkCore(options.Resolve(options.Authorization)) + .AddUltimateAuthSessionsEntityFrameworkCore(options.Resolve(options.Sessions)) + .AddUltimateAuthTokensEntityFrameworkCore(options.Resolve(options.Tokens)) + .AddUltimateAuthAuthenticationEntityFrameworkCore(options.Resolve(options.Authentication)); return services; } + + public static IServiceCollection AddUltimateAuthEfCoreStores(this IServiceCollection services) + { + return services + .AddUltimateAuthUsersEntityFrameworkCore() + .AddUltimateAuthSessionsEntityFrameworkCore() + .AddUltimateAuthTokensEntityFrameworkCore() + .AddUltimateAuthAuthorizationEntityFrameworkCore() + .AddUltimateAuthCredentialsEntityFrameworkCore() + .AddUltimateAuthAuthenticationEntityFrameworkCore(); + } } diff --git a/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UAuthEfCoreOptions.cs b/nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Options/UAuthEfCoreOptions.cs similarity index 100% rename from nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/UAuthEfCoreOptions.cs rename to nuget/CodeBeam.UltimateAuth.EntityFrameworkCore/Options/UAuthEfCoreOptions.cs diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/samples/CodeBeam.UltimateAuth.Sample.Seed/AuthorizationSeedContributor.cs similarity index 86% rename from src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs rename to samples/CodeBeam.UltimateAuth.Sample.Seed/AuthorizationSeedContributor.cs index e9e22abf..da5c0ece 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/AuthorizationSeedContributor.cs @@ -1,24 +1,24 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.InMemory; -namespace CodeBeam.UltimateAuth.Authorization.InMemory; +namespace CodeBeam.UltimateAuth.Sample.Seed; -internal sealed class InMemoryAuthorizationSeedContributor : ISeedContributor +internal sealed class AuthorizationSeedContributor : ISeedContributor { public int Order => 20; private readonly IRoleStoreFactory _roleStoreFactory; private readonly IUserRoleStoreFactory _userRoleStoreFactory; - private readonly IInMemoryUserIdProvider _ids; + private readonly IUserIdProvider _ids; private readonly IClock _clock; - public InMemoryAuthorizationSeedContributor( + public AuthorizationSeedContributor( IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, - IInMemoryUserIdProvider ids, + IUserIdProvider ids, IClock clock) { _roleStoreFactory = roleStoreFactory; diff --git a/samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj b/samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj new file mode 100644 index 00000000..96d9adaa --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/CodeBeam.UltimateAuth.Sample.Seed.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/samples/CodeBeam.UltimateAuth.Sample.Seed/CredentialSeedContributor.cs similarity index 78% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs rename to samples/CodeBeam.UltimateAuth.Sample.Seed/CredentialSeedContributor.cs index 84b01dbc..b49f8753 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/CredentialSeedContributor.cs @@ -4,22 +4,21 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; -using CodeBeam.UltimateAuth.InMemory; -namespace CodeBeam.UltimateAuth.Credentials.InMemory; +namespace CodeBeam.UltimateAuth.Sample.Seed; -internal sealed class InMemoryCredentialSeedContributor : ISeedContributor +internal sealed class CredentialSeedContributor : ISeedContributor { private static readonly Guid _adminPasswordId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); private static readonly Guid _userPasswordId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); public int Order => 10; private readonly IPasswordCredentialStoreFactory _credentialFactory; - private readonly IInMemoryUserIdProvider _ids; + private readonly IUserIdProvider _ids; private readonly IUAuthPasswordHasher _hasher; private readonly IClock _clock; - public InMemoryCredentialSeedContributor(IPasswordCredentialStoreFactory credentialFactory, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) + public CredentialSeedContributor(IPasswordCredentialStoreFactory credentialFactory, IUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) { _credentialFactory = credentialFactory; _ids = ids; @@ -38,6 +37,12 @@ private async Task SeedCredentialAsync(UserKey userKey, Guid credentialId, strin try { var credentialStore = _credentialFactory.Create(tenant); + + var existing = await credentialStore.GetByUserAsync(userKey, ct); + + if (existing.Any(x => x.Id == credentialId)) + return; + await credentialStore.AddAsync( PasswordCredential.Create( credentialId, diff --git a/samples/CodeBeam.UltimateAuth.Sample.Seed/Extensions/ServiceCollectionExtensions.cs b/samples/CodeBeam.UltimateAuth.Sample.Seed/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..25a28910 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Sample.Seed.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthSampleSeed(this IServiceCollection services) + { + services.TryAddSingleton(); + services.AddSingleton, UserIdProvider>(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } + + public static IServiceCollection AddScopedUltimateAuthSampleSeed(this IServiceCollection services) + { + services.TryAddScoped(); + services.AddSingleton, UserIdProvider>(); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + return services; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Seed/IUserIdProvider.cs b/samples/CodeBeam.UltimateAuth.Sample.Seed/IUserIdProvider.cs new file mode 100644 index 00000000..6d4e04be --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/IUserIdProvider.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Sample.Seed; + +public interface IUserIdProvider +{ + TUserId GetAdminUserId(); + TUserId GetUserUserId(); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs b/samples/CodeBeam.UltimateAuth.Sample.Seed/UserIdProvider.cs similarity index 69% rename from src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs rename to samples/CodeBeam.UltimateAuth.Sample.Seed/UserIdProvider.cs index 0d6f4ec8..e2a167bb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/UserIdProvider.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.InMemory; -namespace CodeBeam.UltimateAuth.Users.InMemory; +namespace CodeBeam.UltimateAuth.Sample.Seed; -public sealed class InMemoryUserIdProvider : IInMemoryUserIdProvider +public sealed class UserIdProvider : IUserIdProvider { private static readonly UserKey Admin = UserKey.FromGuid(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); private static readonly UserKey User = UserKey.FromGuid(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs similarity index 88% rename from src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs rename to samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs index 659f30d2..5d285880 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs @@ -1,29 +1,28 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -namespace CodeBeam.UltimateAuth.Users.InMemory; +namespace CodeBeam.UltimateAuth.Sample.Seed; -internal sealed class InMemoryUserSeedContributor : ISeedContributor +public sealed class UserSeedContributor : ISeedContributor { public int Order => 0; private readonly IUserLifecycleStoreFactory _lifecycleFactory; private readonly IUserIdentifierStoreFactory _identifierFactory; private readonly IUserProfileStoreFactory _profileFactory; - private readonly IInMemoryUserIdProvider _ids; + private readonly IUserIdProvider _ids; private readonly IIdentifierNormalizer _identifierNormalizer; private readonly IClock _clock; - public InMemoryUserSeedContributor( + public UserSeedContributor( IUserLifecycleStoreFactory lifecycleFactory, IUserProfileStoreFactory profileFactory, IUserIdentifierStoreFactory identifierFactory, - IInMemoryUserIdProvider ids, + IUserIdProvider ids, IIdentifierNormalizer identifierNormalizer, IClock clock) { @@ -68,10 +67,7 @@ await profileStore.AddAsync( ct); } - async Task EnsureIdentifier( - UserIdentifierType type, - string value, - bool isPrimary) + async Task EnsureIdentifier(UserIdentifierType type, string value, bool isPrimary) { var normalized = _identifierNormalizer .Normalize(type, value).Normalized; diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index fbf939d6..840af929 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -21,6 +21,7 @@ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 47a813c8..9108573e 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -3,6 +3,7 @@ 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; @@ -41,6 +42,8 @@ .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); 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..edeedaa0 --- /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 = SelfUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(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..778ad577 --- /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.CreateAdminAsync(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..f9829141 --- /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.ChangeCredentialAsync(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..c07885b3 --- /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.GetMyIdentifiersAsync(); + 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.GetMyIdentifiersAsync(req); + } + else + { + res = await UAuthClient.Identifiers.GetUserIdentifiersAsync(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.UpdateSelfAsync(updateRequest); + } + else + { + result = await UAuthClient.Identifiers.UpdateAdminAsync(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.AddSelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.AddAdminAsync(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() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.VerifySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.VerifyAdminAsync(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() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.SetPrimaryAdminAsync(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() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.UnsetPrimaryAdminAsync(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() { IdentifierId = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + } + else + { + result = await UAuthClient.Identifiers.DeleteAdminAsync(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..99c3b75b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor.cs @@ -0,0 +1,119 @@ +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 SetPermissionsRequest + { + Permissions = permissions + }; + + var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, 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..868c9c05 --- /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.GetProfileAsync(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.UpdateProfileAsync(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..55f5195c --- /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 BeginCredentialResetRequest + { + 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..349ef670 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor.cs @@ -0,0 +1,163 @@ +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 + { + Name = role.Name + }; + + var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, 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(); + var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, 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..5ec8d396 --- /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.LogoutAllDevicesSelfAsync(); + } + else + { + result = await UAuthClient.Flows.LogoutAllDevicesAdminAsync(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.LogoutOtherDevicesSelfAsync(); + + 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.LogoutDeviceSelfAsync(request); + } + else + { + result = await UAuthClient.Flows.LogoutDeviceAdminAsync(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..28bb738d --- /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..e5bd3a08 --- /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 UserStatus _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.GetProfileAsync(UserKey); + + if (result.IsSuccess) + { + _user = result.Value; + _status = _user?.Status ?? UserStatus.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.ChangeStatusAdminAsync(_user.UserKey, request); + + if (result.IsSuccess) + { + Snackbar.Add("User status updated", Severity.Success); + _user = _user with { Status = _status }; + } + 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..94ec688a --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor.cs @@ -0,0 +1,112 @@ +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 result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); + + 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 result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); + + 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..2344bdf8 --- /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.QueryUsersAsync(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..c5d1da90 --- /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 = SelfUserStatus.Active }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(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..3bdd9f68 --- /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 CompleteCredentialResetRequest + { + 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 0000000000000000000000000000000000000000..4e86411b5803e34b1e4767ce981907694f15c1ed GIT binary patch literal 4096 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 - -
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 index c27fc66a..9d71e7d7 100644 --- 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 @@ -17,7 +17,7 @@ - + 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 index e32cc79c..881cae5c 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor @@ -45,6 +45,12 @@ Sign Up + + + + + Already have an account? SignIn + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 5995bf9f..7b54009b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,15 +1,17 @@ +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; -using CodeBeam.UltimateAuth.Client.Blazor; var builder = WebApplication.CreateBuilder(args); @@ -59,6 +61,7 @@ //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; }); +builder.Services.AddUltimateAuthSampleSeed(); builder.Services.Configure(options => { @@ -67,7 +70,6 @@ ForwardedHeaders.XForwardedProto; }); - var app = builder.Build(); if (!app.Environment.IsDevelopment()) 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 index e32cc79c..881cae5c 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor @@ -45,6 +45,12 @@ Sign Up + + + + + Already have an account? SignIn + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 7bc8536b..6e346489 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -13,36 +13,31 @@ 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.AddUltimateAuth(); builder.Services.AddUltimateAuthClientBlazor(o => { - o.Endpoints.BasePath = "https://localhost:6110/auth"; + 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"; + o.Pkce.ReturnUrl = "https://localhost:6130/home"; // This application domain + path }); -builder.Services.AddMudServices(o => { - o.SnackbarConfiguration.PreventDuplicates = false; -}); -builder.Services.AddMudExtensions(); - builder.Services.AddScoped(); -builder.Services.AddScoped(); - - -//builder.Services.AddHttpClient("ResourceApi", client => -//{ -// client.BaseAddress = new Uri("https://localhost:6120"); -//}); builder.Services.AddScoped(sp => { return new HttpClient { - BaseAddress = new Uri("https://localhost:6120") // Resource API + BaseAddress = new Uri("https://localhost:6120") // Resource API URL }; }); diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs index 35eb05d1..a59b1f61 100644 --- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs @@ -17,13 +17,9 @@ { app.MapOpenApi(); } - app.UseHttpsRedirection(); -app.UseUltimateAuthResourceApi(); -app.UseAuthentication(); -app.UseAuthorization(); +app.UseUltimateAuthResourceApiWithAspNetCore(); app.MapControllers(); - app.Run(); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs index cedcbdfe..bf1bf3cb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -11,10 +12,21 @@ public static class EndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapUltimateAuthEndpoints(this IEndpointRouteBuilder endpoints) { - var registrar = endpoints.ServiceProvider.GetRequiredService(); - var options = endpoints.ServiceProvider.GetRequiredService>().Value; - var rootGroup = endpoints.MapGroup("") - .RequireCors("UAuthHub"); + 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) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 6c6977dd..a9da44d6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -256,8 +256,6 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddScoped(); // Endpoints diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index c78d6427..d352d7f1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -65,4 +65,13 @@ public static IApplicationBuilder UseUltimateAuthResourceApi(this IApplicationBu return app; } + + public static IApplicationBuilder UseUltimateAuthResourceApiWithAspNetCore(this IApplicationBuilder app) + { + app.UseUltimateAuthResourceApi(); + app.UseAuthentication(); + app.UseAuthorization(); + + return app; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 4f1d4909..66d5b8ee 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -67,8 +67,30 @@ await kernel.ExecuteAsync(async _ => if (context.ChainId is not null) { - chain = await kernel.GetChainAsync(context.ChainId.Value) - ?? throw new UAuthNotFoundException("Chain not found."); + 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."); diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs index c2ff82b3..c7c19cc1 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs @@ -1,10 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; -internal sealed class UAuthAuthenticationDbContext : DbContext +public sealed class UAuthAuthenticationDbContext : DbContext { public DbSet AuthenticationSecurityStates => Set(); @@ -14,65 +12,8 @@ public UAuthAuthenticationDbContext(DbContextOptions(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); - - e.Property(x => x.LockedUntil); - - e.Property(x => x.RequiresReauthentication) - .IsRequired(); - - e.Property(x => x.ResetRequestedAt); - e.Property(x => x.ResetExpiresAt); - e.Property(x => x.ResetConsumedAt); - - 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 }); - - }); + 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 index 956212fa..1a9db677 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -6,10 +6,14 @@ namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthAuthenticationEntityFrameworkCore(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthAuthenticationEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext { - services.AddDbContext(configureDb); - services.AddScoped(); + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); return services; } } diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Projections/AuthenticationSecutiryStateProjection.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Projections/AuthenticationSecutiryStateProjection.cs index fc9740ad..266bf15a 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Projections/AuthenticationSecutiryStateProjection.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Projections/AuthenticationSecutiryStateProjection.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; -internal sealed class AuthenticationSecurityStateProjection +public sealed class AuthenticationSecurityStateProjection { public Guid Id { get; set; } diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs index ac247165..fe12bfcd 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs @@ -7,20 +7,22 @@ namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; -internal sealed class EfCoreAuthenticationSecurityStateStore : IAuthenticationSecurityStateStore +internal sealed class EfCoreAuthenticationSecurityStateStore : IAuthenticationSecurityStateStore where TDbContext : DbContext { - private readonly UAuthAuthenticationDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreAuthenticationSecurityStateStore(UAuthAuthenticationDbContext db, TenantContext 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 _db.AuthenticationSecurityStates + var entity = await DbSet .AsNoTracking() .SingleOrDefaultAsync(x => x.Tenant == _tenant && @@ -38,14 +40,14 @@ public async Task AddAsync(AuthenticationSecurityState state, CancellationToken { var entity = AuthenticationSecurityStateMapper.ToProjection(state); - _db.AuthenticationSecurityStates.Add(entity); + DbSet.Add(entity); await _db.SaveChangesAsync(ct); } public async Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default) { - var entity = await _db.AuthenticationSecurityStates + var entity = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.Id == state.Id, @@ -64,7 +66,7 @@ public async Task UpdateAsync(AuthenticationSecurityState state, long expectedVe public async Task DeleteAsync(UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) { - var entity = await _db.AuthenticationSecurityStates + var entity = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey && @@ -75,7 +77,7 @@ public async Task DeleteAsync(UserKey userKey, AuthenticationSecurityScope scope if (entity is null) return; - _db.AuthenticationSecurityStates.Remove(entity); + 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 index 74ab7382..5f897cf7 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs @@ -1,19 +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 +internal sealed class EfCoreAuthenticationSecurityStateStoreFactory : IAuthenticationSecurityStateStoreFactory where TDbContext : DbContext { - private readonly UAuthAuthenticationDbContext _db; + private readonly TDbContext _db; - public EfCoreAuthenticationSecurityStateStoreFactory(UAuthAuthenticationDbContext db) + public EfCoreAuthenticationSecurityStateStoreFactory(TDbContext db) { _db = db; } public IAuthenticationSecurityStateStore Create(TenantKey tenant) { - return new EfCoreAuthenticationSecurityStateStore(_db, new TenantContext(tenant)); + return new EfCoreAuthenticationSecurityStateStore(_db, new TenantContext(tenant)); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs index 7783f457..187e1d4d 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs @@ -1,11 +1,8 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; -internal sealed class UAuthAuthorizationDbContext : DbContext +public sealed class UAuthAuthorizationDbContext : DbContext { public DbSet Roles => Set(); public DbSet RolePermissions => Set(); @@ -16,115 +13,8 @@ public UAuthAuthorizationDbContext(DbContextOptions { } - protected override void OnModelCreating(ModelBuilder b) + protected override void OnModelCreating(ModelBuilder modelBuilder) { - ConfigureRole(b); - ConfigureRolePermission(b); - ConfigureUserRole(b); + UAuthAuthorizationModelBuilder.Configure(modelBuilder); } - - private void ConfigureRole(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) - .HasConversion( - v => v.UtcDateTime, - v => new DateTimeOffset(v, TimeSpan.Zero)); - - e.HasIndex(x => new { x.Tenant, x.Id }).IsUnique(); - e.HasIndex(x => new { x.Tenant, x.NormalizedName }).IsUnique(); - }); - } - - private void ConfigureRolePermission(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 void ConfigureUserRole(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) - .IsRequired(); - - e.HasIndex(x => new { x.Tenant, x.UserKey }); - e.HasIndex(x => new { x.Tenant, x.RoleId }); - }); - } -} \ No newline at end of file +} 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 index b4554cd4..8ed556c8 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -5,11 +5,15 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthAuthorizationEntityFrameworkCore(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthAuthorizationEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext { - services.AddDbContext(configureDb); - services.AddScoped(); - services.AddScoped(); + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); + services.AddScoped>(); return services; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RolePermissionProjection.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RolePermissionProjection.cs index 571425a7..bc12b9a0 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RolePermissionProjection.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RolePermissionProjection.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; -internal sealed class RolePermissionProjection +public sealed class RolePermissionProjection { public TenantKey Tenant { get; set; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs index 1c4e155a..e4e5e80e 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; -internal sealed class RoleProjection +public sealed class RoleProjection { public RoleId Id { get; set; } @@ -14,9 +14,7 @@ internal sealed class RoleProjection 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 index 42f1f186..22f956e2 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/UserRoleProjection.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/UserRoleProjection.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; -internal sealed class UserRoleProjection +public sealed class UserRoleProjection { public TenantKey Tenant { get; set; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs index 8c58de33..f5151afa 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs @@ -6,20 +6,23 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; -internal sealed class EfCoreRoleStore : IRoleStore +internal sealed class EfCoreRoleStore : IRoleStore where TDbContext : DbContext { - private readonly UAuthAuthorizationDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreRoleStore(UAuthAuthorizationDbContext db, TenantContext 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 _db.Roles + return await DbSetRole .AnyAsync(x => x.Tenant == _tenant && x.Id == key.RoleId, @@ -28,7 +31,7 @@ public async Task ExistsAsync(RoleKey key, CancellationToken ct = default) public async Task AddAsync(Role role, CancellationToken ct = default) { - var exists = await _db.Roles + var exists = await DbSetRole .AnyAsync(x => x.Tenant == _tenant && x.NormalizedName == role.NormalizedName && @@ -40,19 +43,19 @@ public async Task AddAsync(Role role, CancellationToken ct = default) var entity = RoleMapper.ToProjection(role); - _db.Roles.Add(entity); + DbSetRole.Add(entity); var permissionEntities = role.Permissions .Select(p => RolePermissionMapper.ToProjection(_tenant, role.Id, p)); - _db.RolePermissions.AddRange(permissionEntities); + DbSetPermission.AddRange(permissionEntities); await _db.SaveChangesAsync(ct); } public async Task GetAsync(RoleKey key, CancellationToken ct = default) { - var entity = await _db.Roles + var entity = await DbSetRole .AsNoTracking() .SingleOrDefaultAsync(x => x.Tenant == _tenant && @@ -62,7 +65,7 @@ public async Task AddAsync(Role role, CancellationToken ct = default) if (entity is null) return null; - var permissions = await _db.RolePermissions + var permissions = await DbSetPermission .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -74,7 +77,7 @@ public async Task AddAsync(Role role, CancellationToken ct = default) public async Task SaveAsync(Role role, long expectedVersion, CancellationToken ct = default) { - var entity = await _db.Roles + var entity = await DbSetRole .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.Id == role.Id, @@ -88,7 +91,7 @@ public async Task SaveAsync(Role role, long expectedVersion, CancellationToken c if (entity.NormalizedName != role.NormalizedName) { - var exists = await _db.Roles + var exists = await DbSetRole .AnyAsync(x => x.Tenant == _tenant && x.NormalizedName == role.NormalizedName && @@ -103,25 +106,25 @@ public async Task SaveAsync(Role role, long expectedVersion, CancellationToken c RoleMapper.UpdateProjection(role, entity); entity.Version++; - var existingPermissions = await _db.RolePermissions + var existingPermissions = await DbSetPermission .Where(x => x.Tenant == _tenant && x.RoleId == role.Id) .ToListAsync(ct); - _db.RolePermissions.RemoveRange(existingPermissions); + DbSetPermission.RemoveRange(existingPermissions); var newPermissions = role.Permissions .Select(p => RolePermissionMapper.ToProjection(_tenant, role.Id, p)); - _db.RolePermissions.AddRange(newPermissions); + 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 _db.Roles + var entity = await DbSetRole .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.Id == key.RoleId, @@ -135,13 +138,13 @@ public async Task DeleteAsync(RoleKey key, long expectedVersion, DeleteMode mode if (mode == DeleteMode.Hard) { - await _db.RolePermissions + await DbSetPermission .Where(x => x.Tenant == _tenant && x.RoleId == key.RoleId) .ExecuteDeleteAsync(ct); - _db.Roles.Remove(entity); + DbSetRole.Remove(entity); } else { @@ -154,7 +157,7 @@ await _db.RolePermissions public async Task GetByNameAsync(string normalizedName, CancellationToken ct = default) { - var entity = await _db.Roles + var entity = await DbSetRole .AsNoTracking() .SingleOrDefaultAsync(x => x.Tenant == _tenant && @@ -165,7 +168,7 @@ await _db.RolePermissions if (entity is null) return null; - var permissions = await _db.RolePermissions + var permissions = await DbSetPermission .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -179,7 +182,7 @@ public async Task> GetByIdsAsync( IReadOnlyCollection roleIds, CancellationToken ct = default) { - var entities = await _db.Roles + var entities = await DbSetRole .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -188,7 +191,7 @@ public async Task> GetByIdsAsync( var roleIdsSet = entities.Select(x => x.Id).ToList(); - var permissions = await _db.RolePermissions + var permissions = await DbSetPermission .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -217,7 +220,7 @@ public async Task> QueryAsync( { var normalized = query.Normalize(); - var baseQuery = _db.Roles + var baseQuery = DbSetRole .AsNoTracking() .Where(x => x.Tenant == _tenant); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs index 975dc443..ed02cd5e 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs @@ -1,18 +1,19 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; -internal sealed class EfCoreRoleStoreFactory : IRoleStoreFactory +internal sealed class EfCoreRoleStoreFactory : IRoleStoreFactory where TDbContext : DbContext { - private readonly UAuthAuthorizationDbContext _db; + private readonly TDbContext _db; - public EfCoreRoleStoreFactory(UAuthAuthorizationDbContext db) + public EfCoreRoleStoreFactory(TDbContext db) { _db = db; } public IRoleStore Create(TenantKey tenant) { - return new EfCoreRoleStore(_db, new TenantContext(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 index 24353a98..a99234f0 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs @@ -6,20 +6,22 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; -internal sealed class EfCoreUserRoleStore : IUserRoleStore +internal sealed class EfCoreUserRoleStore : IUserRoleStore where TDbContext : DbContext { - private readonly UAuthAuthorizationDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreUserRoleStore(UAuthAuthorizationDbContext db, TenantContext 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 _db.UserRoles + var entities = await DbSet .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -31,7 +33,7 @@ public async Task> GetAssignmentsAsync(UserKey use public async Task AssignAsync(UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) { - var exists = await _db.UserRoles + var exists = await DbSet .AnyAsync(x => x.Tenant == _tenant && x.UserKey == userKey && @@ -49,13 +51,13 @@ public async Task AssignAsync(UserKey userKey, RoleId roleId, DateTimeOffset ass AssignedAt = assignedAt }; - _db.UserRoles.Add(entity); + DbSet.Add(entity); await _db.SaveChangesAsync(ct); } public async Task RemoveAsync(UserKey userKey, RoleId roleId, CancellationToken ct = default) { - var entity = await _db.UserRoles + var entity = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey && @@ -65,13 +67,13 @@ public async Task RemoveAsync(UserKey userKey, RoleId roleId, CancellationToken if (entity is null) return; - _db.UserRoles.Remove(entity); + DbSet.Remove(entity); await _db.SaveChangesAsync(ct); } public async Task RemoveAssignmentsByRoleAsync(RoleId roleId, CancellationToken ct = default) { - await _db.UserRoles + await DbSet .Where(x => x.Tenant == _tenant && x.RoleId == roleId) @@ -80,7 +82,7 @@ await _db.UserRoles public async Task CountAssignmentsAsync(RoleId roleId, CancellationToken ct = default) { - return await _db.UserRoles + return await DbSet .CountAsync(x => x.Tenant == _tenant && x.RoleId == roleId, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs index 516b5e9b..74132289 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs @@ -1,18 +1,19 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; -internal sealed class EfCoreUserRoleStoreFactory : IUserRoleStoreFactory +internal sealed class EfCoreUserRoleStoreFactory : IUserRoleStoreFactory where TDbContext : DbContext { - private readonly UAuthAuthorizationDbContext _db; + private readonly TDbContext _db; - public EfCoreUserRoleStoreFactory(UAuthAuthorizationDbContext db) + public EfCoreUserRoleStoreFactory(TDbContext db) { _db = db; } public IUserRoleStore Create(TenantKey tenant) { - return new EfCoreUserRoleStore(_db, new TenantContext(tenant)); + return new EfCoreUserRoleStore(_db, new TenantContext(tenant)); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs index 3ce68afc..919b6a61 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -11,9 +11,6 @@ public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServ services.TryAddSingleton(); services.TryAddSingleton(); - // Never try add - seeding is enumerated and all contributors are added. - services.AddSingleton(); - return services; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs deleted file mode 100644 index 750a4d66..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.InMemory; - -public interface IAuthorizationSeeder -{ - Task SeedAsync(CancellationToken ct = default); -} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 062de526..849e6fe0 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -23,17 +23,15 @@ internal class UAuthFlowClient : IFlowClient private readonly IUAuthRequestClient _post; private readonly IUAuthClientEvents _events; private readonly IClientDeviceProvider _clientDeviceProvider; - private readonly IDeviceIdProvider _deviceIdProvider; private readonly IReturnUrlProvider _returnUrlProvider; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; - public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IClientDeviceProvider clientDeviceProvider, IDeviceIdProvider deviceIdProvider, IReturnUrlProvider returnUrlProvider, IOptions options, UAuthClientDiagnostics diagnostics) + public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IClientDeviceProvider clientDeviceProvider, IReturnUrlProvider returnUrlProvider, IOptions options, UAuthClientDiagnostics diagnostics) { _post = post; _events = events; _clientDeviceProvider = clientDeviceProvider; - _deviceIdProvider = deviceIdProvider; _returnUrlProvider = returnUrlProvider; _options = options.Value; _diagnostics = diagnostics; @@ -64,13 +62,22 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi { case UAuthSubmitMode.TryOnly: { - var result = await _post.SendJsonAsync(tryUrl, request); + var result = await _post.SendJsonAsync(tryUrl, payload); if (result.Body is null) throw new UAuthProtocolException("Empty response body."); - var parsed = result.Body.Value.Deserialize( - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + 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."); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs index 6e40935a..fb5a975d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs @@ -1,10 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; -internal sealed class UAuthCredentialDbContext : DbContext +public sealed class UAuthCredentialDbContext : DbContext { public DbSet PasswordCredentials => Set(); @@ -13,52 +11,8 @@ public UAuthCredentialDbContext(DbContextOptions optio { } - protected override void OnModelCreating(ModelBuilder b) + protected override void OnModelCreating(ModelBuilder modelBuilder) { - ConfigurePasswordCredential(b); + UAuthCredentialsModelBuilder.Configure(modelBuilder); } - - private void ConfigurePasswordCredential(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.RevokedAt); - e.Property(x => x.ExpiresAt); - e.Property(x => x.LastUsedAt); - e.Property(x => x.Source).HasMaxLength(128); - e.Property(x => x.CreatedAt).IsRequired(); - e.Property(x => x.UpdatedAt); - e.Property(x => x.DeletedAt); - - 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 }); - }); - } -} \ No newline at end of file +} 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 index 23f8b844..7c390a43 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -6,10 +6,14 @@ namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthCredentialsEntityFrameworkCore(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthCredentialsEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext { - services.AddDbContext(configureDb); - services.AddScoped(); + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); return services; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs index d25e8edc..ae4a8dee 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; -internal sealed class PasswordCredentialProjection +public sealed class PasswordCredentialProjection { public Guid Id { get; set; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs index bf533435..383bd7e0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs @@ -8,20 +8,22 @@ namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; -internal sealed class EfCorePasswordCredentialStore : IPasswordCredentialStore +internal sealed class EfCorePasswordCredentialStore : IPasswordCredentialStore where TDbContext : DbContext { - private readonly UAuthCredentialDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCorePasswordCredentialStore(UAuthCredentialDbContext db, TenantContext 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 _db.PasswordCredentials + return await DbSet .AnyAsync(x => x.Id == key.Id && x.Tenant == _tenant, @@ -32,14 +34,14 @@ public async Task AddAsync(PasswordCredential credential, CancellationToken ct = { var entity = credential.ToProjection(); - _db.PasswordCredentials.Add(entity); + DbSet.Add(entity); await _db.SaveChangesAsync(ct); } public async Task GetAsync(CredentialKey key, CancellationToken ct = default) { - var entity = await _db.PasswordCredentials + var entity = await DbSet .AsNoTracking() .SingleOrDefaultAsync( x => x.Id == key.Id && @@ -51,7 +53,7 @@ public async Task AddAsync(PasswordCredential credential, CancellationToken ct = public async Task SaveAsync(PasswordCredential credential, long expectedVersion, CancellationToken ct = default) { - var entity = await _db.PasswordCredentials + var entity = await DbSet .SingleOrDefaultAsync(x => x.Id == credential.Id && x.Tenant == _tenant, @@ -71,7 +73,7 @@ public async Task SaveAsync(PasswordCredential credential, long expectedVersion, public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) { - var entity = await _db.PasswordCredentials + var entity = await DbSet .SingleOrDefaultAsync(x => x.Id == key.Id && x.Tenant == _tenant, @@ -93,7 +95,7 @@ public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { - var entity = await _db.PasswordCredentials + var entity = await DbSet .SingleOrDefaultAsync(x => x.Id == key.Id && x.Tenant == _tenant, @@ -107,7 +109,7 @@ public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMod if (mode == DeleteMode.Hard) { - _db.PasswordCredentials.Remove(entity); + DbSet.Remove(entity); } else { @@ -121,7 +123,7 @@ public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMod public async Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) { - var entities = await _db.PasswordCredentials + var entities = await DbSet .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -139,7 +141,7 @@ public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOf { if (mode == DeleteMode.Hard) { - await _db.PasswordCredentials + await DbSet .Where(x => x.Tenant == _tenant && x.UserKey == userKey) @@ -148,7 +150,7 @@ await _db.PasswordCredentials return; } - await _db.PasswordCredentials + await DbSet .Where(x => x.Tenant == _tenant && x.UserKey == userKey && diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs index ba037a79..13a0a4a7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs @@ -1,19 +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 +internal sealed class EfCorePasswordCredentialStoreFactory : IPasswordCredentialStoreFactory where TDbContext : DbContext { - private readonly UAuthCredentialDbContext _db; + private readonly TDbContext _db; - public EfCorePasswordCredentialStoreFactory(UAuthCredentialDbContext db) + public EfCorePasswordCredentialStoreFactory(TDbContext db) { _db = db; } public IPasswordCredentialStore Create(TenantKey tenant) { - return new EfCorePasswordCredentialStore(_db, new TenantContext(tenant)); + return new EfCorePasswordCredentialStore(_db, new TenantContext(tenant)); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs index ef75640c..9b53af9c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -11,9 +11,6 @@ public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServic { services.TryAddSingleton(); - // Never try add seed - services.AddSingleton(); - return services; } } 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/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index 886762d5..93fcd842 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -1,173 +1,20 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class UAuthSessionDbContext : DbContext +public sealed class UAuthSessionDbContext : DbContext { public DbSet Roots => Set(); public DbSet Chains => Set(); public DbSet Sessions => Set(); - public UAuthSessionDbContext(DbContextOptions options) : base(options) + public UAuthSessionDbContext(DbContextOptions options) : base(options) { } - protected override void OnModelCreating(ModelBuilder b) + protected override void OnModelCreating(ModelBuilder modelBuilder) { - b.Entity(e => - { - e.ToTable("UAuth_SessionRoots"); - e.HasKey(x => x.Id); - - e.Property(x => x.Version).IsConcurrencyToken(); - e.Property(x => x.CreatedAt).IsRequired(); - - 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.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); - e.HasIndex(x => new { x.Tenant, x.RootId }).IsUnique(); - - e.Property(x => x.SecurityVersion) - .IsRequired(); - - e.Property(x => x.RootId) - .HasConversion( - v => v.Value, - v => SessionRootId.From(v)) - .HasMaxLength(128) - .IsRequired(); - }); - - b.Entity(e => - { - e.ToTable("UAuth_SessionChains"); - e.HasKey(x => x.Id); - - e.Property(x => x.Version).IsConcurrencyToken(); - e.Property(x => x.CreatedAt).IsRequired(); - - 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.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); - - 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(); - }); - - b.Entity(e => - { - e.ToTable("UAuth_Sessions"); - e.HasKey(x => x.Id); - e.Property(x => x.Version).IsConcurrencyToken(); - e.Property(x => x.CreatedAt).IsRequired(); - - 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.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); - - 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(); - }); + 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 index 69762efe..2dc070af 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -6,10 +6,14 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthSessionsEntityFrameworkCore(this IServiceCollection services,Action configureDb) + public static IServiceCollection AddUltimateAuthSessionsEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext { - services.AddDbContext(configureDb); - services.AddScoped(); + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs index ffc23f10..c9f417a9 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class SessionChainProjection +public sealed class SessionChainProjection { public long Id { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs index 19061c50..7860d578 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs @@ -3,20 +3,17 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class SessionProjection +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; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs index 4d9c0ff8..c6ae69d9 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class SessionRootProjection +public sealed class SessionRootProjection { public long Id { get; set; } public SessionRootId RootId { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index c9a31e6e..ca86ff54 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -7,17 +7,21 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class EfCoreSessionStore : ISessionStore +internal sealed class EfCoreSessionStore : ISessionStore where TDbContext : DbContext { - private readonly UAuthSessionDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreSessionStore(UAuthSessionDbContext db, TenantContext 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(); @@ -77,7 +81,12 @@ public async Task ExecuteAsync(Func 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); @@ -88,11 +97,16 @@ public async Task SaveSessionAsync(UAuthSession session, long expectedVersion, C { ct.ThrowIfCancellationRequested(); - var projection = await _db.Sessions + 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"); @@ -113,7 +127,7 @@ public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = defa if (session.Version != 0) throw new InvalidOperationException("New session must have version 0."); - _db.Sessions.Add(projection); + DbSetSession.Add(projection); return Task.CompletedTask; } @@ -122,7 +136,7 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs { ct.ThrowIfCancellationRequested(); - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId, ct); + var projection = await DbSetSession.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId, ct); if (projection is null || projection.RevokedAt is not null) return false; @@ -138,13 +152,13 @@ public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, Cancel { ct.ThrowIfCancellationRequested(); - var chains = await _db.Chains + 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 _db.Sessions + var sessions = await DbSetSession .Where(x => x.Tenant == _tenant && chainIds.Contains(x.ChainId)) .ToListAsync(ct); @@ -174,13 +188,13 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai { ct.ThrowIfCancellationRequested(); - var chains = await _db.Chains + 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 _db.Sessions + var sessions = await DbSetSession .Where(x => x.Tenant == _tenant && chainIds.Contains(x.ChainId)) .ToListAsync(ct); @@ -210,9 +224,14 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai { ct.ThrowIfCancellationRequested(); - var projection = await _db.Chains + 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); + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); return projection?.ToDomain(); } @@ -221,14 +240,25 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai { ct.ThrowIfCancellationRequested(); - var projection = await _db.Chains + 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) - .SingleOrDefaultAsync(ct); + .FirstOrDefaultAsync(ct); return projection?.ToDomain(); } @@ -237,11 +267,13 @@ public async Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, { ct.ThrowIfCancellationRequested(); - var projection = await _db.Chains - .SingleOrDefaultAsync(x => - x.Tenant == _tenant && - x.ChainId == chain.ChainId, - ct); + 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"); @@ -262,7 +294,8 @@ public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = def var projection = chain.ToProjection(); - _db.Chains.Add(projection); + DbSetChain.Add(projection); + _db.Entry(projection).State = EntityState.Added; return Task.CompletedTask; } @@ -271,7 +304,7 @@ public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, Ca { ct.ThrowIfCancellationRequested(); - var projection = await _db.Chains + var projection = await DbSetChain .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); if (projection is null || projection.RevokedAt is not null) @@ -286,13 +319,13 @@ public async Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, Ca { ct.ThrowIfCancellationRequested(); - var chainProjection = await _db.Chains + 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 _db.Sessions + var sessions = await DbSetSession .Where(x => x.Tenant == _tenant && x.ChainId == chainId) .ToListAsync(ct); @@ -319,7 +352,7 @@ public async Task RevokeOtherChainsAsync(UserKey userKey, SessionChainId current { ct.ThrowIfCancellationRequested(); - var projections = await _db.Chains + var projections = await DbSetChain .Where(x => x.Tenant == _tenant && x.UserKey == userKey && @@ -339,7 +372,7 @@ public async Task RevokeAllChainsAsync(UserKey userKey, DateTimeOffset at, Cance { ct.ThrowIfCancellationRequested(); - var projections = await _db.Chains + var projections = await DbSetChain .Where(x => x.Tenant == _tenant && x.UserKey == userKey && @@ -358,7 +391,7 @@ public async Task RevokeAllChainsAsync(UserKey userKey, DateTimeOffset at, Cance { ct.ThrowIfCancellationRequested(); - return await _db.Chains + return await DbSetChain .AsNoTracking() .Where(x => x.Tenant == _tenant && x.ChainId == chainId) .Select(x => x.ActiveSessionId) @@ -369,17 +402,15 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId { ct.ThrowIfCancellationRequested(); - var projection = _db.Chains.Local - .FirstOrDefault(x => x.Tenant == _tenant && x.ChainId == chainId); + var projection = DbSetChain.Local.FirstOrDefault(x => x.Tenant == _tenant && x.ChainId == chainId); if (projection is null) { - projection = await _db.Chains - .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); + projection = await DbSetChain.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); } if (projection is null) - return; + throw new UAuthNotFoundException("chain_not_found"); projection.ActiveSessionId = sessionId; projection.Version++; @@ -389,7 +420,7 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); + var rootProjection = await DbSetRoot.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); return rootProjection?.ToDomain(); } @@ -397,7 +428,7 @@ public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Can { ct.ThrowIfCancellationRequested(); - var projection = await _db.Roots + var projection = await DbSetRoot .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == root.UserKey, @@ -422,7 +453,7 @@ public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = defaul var projection = root.ToProjection(); - _db.Roots.Add(projection); + DbSetRoot.Add(projection); return Task.CompletedTask; } @@ -431,7 +462,7 @@ public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, Cancellati { ct.ThrowIfCancellationRequested(); - var projection = await _db.Roots + var projection = await DbSetRoot .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); if (projection is null || projection.RevokedAt is not null) @@ -446,7 +477,7 @@ public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, Cancellati { ct.ThrowIfCancellationRequested(); - return await _db.Sessions + return await DbSetSession .AsNoTracking() .Where(x => x.Tenant == _tenant && x.SessionId == sessionId) .Select(x => (SessionChainId?)x.ChainId) @@ -457,7 +488,7 @@ public async Task> GetChainsByUserAsync(UserKey { ct.ThrowIfCancellationRequested(); - var rootsQuery = _db.Roots.AsNoTracking().Where(x => x.Tenant == _tenant && x.UserKey == userKey); + var rootsQuery = DbSetRoot.AsNoTracking().Where(x => x.Tenant == _tenant && x.UserKey == userKey); if (!includeHistoricalRoots) { @@ -469,7 +500,7 @@ public async Task> GetChainsByUserAsync(UserKey if (rootIds.Count == 0) return Array.Empty(); - var projections = await _db.Chains.AsNoTracking().Where(x => x.Tenant == _tenant && rootIds.Contains(x.RootId)).ToListAsync(); + var projections = await DbSetChain.AsNoTracking().Where(x => x.Tenant == _tenant && rootIds.Contains(x.RootId)).ToListAsync(); return projections.Select(c => c.ToDomain()).ToList(); } @@ -477,7 +508,7 @@ public async Task> GetChainsByRootAsync(Session { ct.ThrowIfCancellationRequested(); - var projections = await _db.Chains + var projections = await DbSetChain .AsNoTracking() .Where(x => x.Tenant == _tenant && x.RootId == rootId) .ToListAsync(); @@ -489,7 +520,7 @@ public async Task> GetSessionsByChainAsync(SessionCh { ct.ThrowIfCancellationRequested(); - var projections = await _db.Sessions + var projections = await DbSetSession .AsNoTracking() .Where(x => x.Tenant == _tenant && x.ChainId == chainId) .ToListAsync(); @@ -501,7 +532,7 @@ public async Task> GetSessionsByChainAsync(SessionCh { ct.ThrowIfCancellationRequested(); - var projection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.RootId == rootId, ct); + var projection = await DbSetRoot.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.RootId == rootId, ct); return projection?.ToDomain(); } @@ -509,25 +540,25 @@ public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken { ct.ThrowIfCancellationRequested(); - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId, ct); + var projection = await DbSetSession.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId, ct); if (projection is null) return; - _db.Sessions.Remove(projection); + DbSetSession.Remove(projection); } public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var chainProjection = await _db.Chains + var chainProjection = await DbSetChain .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); if (chainProjection is null) return; - var sessionProjections = await _db.Sessions + var sessionProjections = await DbSetSession .Where(x => x.Tenant == _tenant && x.ChainId == chainId && x.RevokedAt == null) .ToListAsync(ct); @@ -550,19 +581,19 @@ public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Can { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots + var rootProjection = await DbSetRoot .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); if (rootProjection is null) return; - var chainProjections = await _db.Chains + var chainProjections = await DbSetChain .Where(x => x.Tenant == _tenant && x.UserKey == userKey) .ToListAsync(ct); foreach (var chainProjection in chainProjections) { - var sessions = await _db.Sessions + var sessions = await DbSetSession .Where(x => x.Tenant == _tenant && x.ChainId == chainProjection.ChainId) .ToListAsync(ct); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs index b64206f1..363e3738 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs @@ -1,19 +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 +internal sealed class EfCoreSessionStoreFactory : ISessionStoreFactory where TDbContext : DbContext { - private readonly UAuthSessionDbContext _db; + private readonly TDbContext _db; - public EfCoreSessionStoreFactory(UAuthSessionDbContext db) + public EfCoreSessionStoreFactory(TDbContext db) { _db = db; } public ISessionStore Create(TenantKey tenant) { - return new EfCoreSessionStore(_db, new TenantContext(tenant)); + return new EfCoreSessionStore(_db, new TenantContext(tenant)); } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs index 304b7a74..032fc0cd 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs @@ -1,11 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; -internal sealed class UAuthTokenDbContext : DbContext +public sealed class UAuthTokenDbContext : DbContext { public DbSet RefreshTokens => Set(); //public DbSet RevokedTokenIds => Set(); // TODO: Add when JWT added. @@ -15,57 +12,8 @@ public UAuthTokenDbContext(DbContextOptions options) { } - protected override void OnModelCreating(ModelBuilder b) + protected override void OnModelCreating(ModelBuilder modelBuilder) { - 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.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 }); - - e.Property(x => x.SessionId) - .HasConversion(new AuthSessionIdConverter()); - - e.Property(x => x.ChainId) - .HasConversion(new NullableSessionChainIdConverter()); - - e.Property(x => x.ExpiresAt) - .IsRequired(); - }); + 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 index 66a01476..11410052 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -6,10 +6,14 @@ namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthTokensEntityFrameworkCore(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthTokensEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext { - services.AddDbContext(configureDb); - services.AddScoped(); + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); return services; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index 3f47970e..2e67b489 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; -internal sealed class RefreshTokenProjection +public sealed class RefreshTokenProjection { public long Id { get; set; } // EF PK @@ -22,9 +22,7 @@ internal sealed class RefreshTokenProjection 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/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs index 6d4aad70..b4be2ef5 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -5,18 +5,20 @@ namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; -internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore +internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore where TDbContext : DbContext { - private readonly UAuthTokenDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; private bool _inTransaction; - public EfCoreRefreshTokenStore(UAuthTokenDbContext db, TenantContext tenant) + 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(); @@ -88,7 +90,7 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) if (token.Tenant != _tenant) throw new InvalidOperationException("Tenant mismatch."); - _db.RefreshTokens.Add(token.ToProjection()); + DbSet.Add(token.ToProjection()); return Task.CompletedTask; } @@ -97,7 +99,7 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var p = await _db.RefreshTokens + var p = await DbSet .AsNoTracking() .SingleOrDefaultAsync( x => x.Tenant == _tenant && @@ -112,7 +114,7 @@ public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? repl ct.ThrowIfCancellationRequested(); EnsureTransaction(); - var query = _db.RefreshTokens + var query = DbSet .Where(x => x.Tenant == _tenant && x.TokenHash == tokenHash && @@ -137,7 +139,7 @@ public Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revoked ct.ThrowIfCancellationRequested(); EnsureTransaction(); - return _db.RefreshTokens + return DbSet .Where(x => x.Tenant == _tenant && x.SessionId == sessionId && @@ -152,7 +154,7 @@ public Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, ct.ThrowIfCancellationRequested(); EnsureTransaction(); - return _db.RefreshTokens + return DbSet .Where(x => x.Tenant == _tenant && x.ChainId == chainId && @@ -167,7 +169,7 @@ public Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, Can ct.ThrowIfCancellationRequested(); EnsureTransaction(); - return _db.RefreshTokens + return DbSet .Where(x => x.Tenant == _tenant && x.UserKey == userKey && diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs index f584beae..9cc94371 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs @@ -1,19 +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 +internal sealed class EfCoreRefreshTokenStoreFactory : IRefreshTokenStoreFactory where TDbContext : DbContext { - private readonly UAuthTokenDbContext _db; + private readonly TDbContext _db; - public EfCoreRefreshTokenStoreFactory(UAuthTokenDbContext db) + public EfCoreRefreshTokenStoreFactory(TDbContext db) { _db = db; } public IRefreshTokenStore Create(TenantKey tenant) { - return new EfCoreRefreshTokenStore(_db, new TenantContext(tenant)); + return new EfCoreRefreshTokenStore(_db, new TenantContext(tenant)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs index f7f0d05c..88dbd3f2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs @@ -1,14 +1,11 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class UAuthUserDbContext : DbContext +public sealed class UAuthUserDbContext : DbContext { - public DbSet Identifiers => Set(); public DbSet Lifecycles => Set(); + public DbSet Identifiers => Set(); public DbSet Profiles => Set(); public UAuthUserDbContext(DbContextOptions options) @@ -16,122 +13,8 @@ public UAuthUserDbContext(DbContextOptions options) { } - protected override void OnModelCreating(ModelBuilder b) - { - ConfigureIdentifiers(b); - ConfigureLifecycles(b); - ConfigureProfiles(b); - } - - private 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.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 }); - - e.Property(x => x.CreatedAt) - .IsRequired(); - }); - } - - private 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.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); - - e.Property(x => x.SecurityVersion) - .IsRequired(); - - e.Property(x => x.CreatedAt) - .IsRequired(); - }); - } - - private void ConfigureProfiles(ModelBuilder b) + protected override void OnModelCreating(ModelBuilder modelBuilder) { - 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.HasIndex(x => new { x.Tenant, x.UserKey }); - - e.Property(x => x.Metadata) - .HasConversion(new NullableJsonValueConverter>()) - .Metadata.SetValueComparer(JsonValueComparers.Create>()); - - e.Property(x => x.CreatedAt) - .IsRequired(); - }); + UAuthUsersModelBuilder.Configure(modelBuilder); } -} \ No newline at end of file +} 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 index 656bc78b..3c9162ab 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -6,12 +6,16 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthUsersEntityFrameworkCore(this IServiceCollection services, Action configureDb) + public static IServiceCollection AddUltimateAuthUsersEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext { - services.AddDbContext(configureDb); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + 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/Projections/UserIdentifierProjections.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs index 1600e974..44ecf317 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class UserIdentifierProjection +public sealed class UserIdentifierProjection { public Guid Id { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs index 0f33546f..07ea8cde 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class UserLifecycleProjection +public sealed class UserLifecycleProjection { public Guid Id { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs index 6e5f07cd..90dfed20 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class UserProfileProjection +public sealed class UserProfileProjection { public Guid Id { get; set; } @@ -32,9 +32,7 @@ internal sealed class UserProfileProjection 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/Stores/EFCoreUserProfileStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs index 8ac0d8c7..e3c4147e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs @@ -1,19 +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 +internal sealed class EfCoreUserProfileStoreFactory : IUserProfileStoreFactory where TDbContext : DbContext { - private readonly UAuthUserDbContext _db; + private readonly TDbContext _db; - public EfCoreUserProfileStoreFactory(UAuthUserDbContext db) + public EfCoreUserProfileStoreFactory(TDbContext db) { _db = db; } public IUserProfileStore Create(TenantKey tenant) { - return new EfCoreUserProfileStore(_db, new TenantContext(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 index 5f85b529..e52bb943 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -8,22 +8,24 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class EfCoreUserIdentifierStore : IUserIdentifierStore +internal sealed class EfCoreUserIdentifierStore : IUserIdentifierStore where TDbContext : DbContext { - private readonly UAuthUserDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreUserIdentifierStore(UAuthUserDbContext db, TenantContext 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 _db.Identifiers + return await DbSet .AnyAsync(x => x.Id == key && x.Tenant == _tenant, @@ -34,7 +36,7 @@ public async Task ExistsAsync(IdentifierExistenceQuer { ct.ThrowIfCancellationRequested(); - var q = _db.Identifiers + var q = DbSet .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -82,7 +84,7 @@ public async Task ExistsAsync(IdentifierExistenceQuer { ct.ThrowIfCancellationRequested(); - var projection = await _db.Identifiers + var projection = await DbSet .AsNoTracking() .SingleOrDefaultAsync(x => x.Id == key && @@ -96,7 +98,7 @@ public async Task ExistsAsync(IdentifierExistenceQuer { ct.ThrowIfCancellationRequested(); - var projection = await _db.Identifiers + var projection = await DbSet .AsNoTracking() .SingleOrDefaultAsync( x => @@ -113,7 +115,7 @@ public async Task ExistsAsync(IdentifierExistenceQuer { ct.ThrowIfCancellationRequested(); - var projection = await _db.Identifiers + var projection = await DbSet .AsNoTracking() .SingleOrDefaultAsync(x => x.Id == id && @@ -127,7 +129,7 @@ public async Task> GetByUserAsync(UserKey userKey, { ct.ThrowIfCancellationRequested(); - var projections = await _db.Identifiers + var projections = await DbSet .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -152,7 +154,7 @@ public async Task AddAsync(UserIdentifier entity, CancellationToken ct = default if (entity.IsPrimary) { - await _db.Identifiers + await DbSet .Where(x => x.Tenant == _tenant && x.UserKey == entity.UserKey && @@ -164,7 +166,7 @@ await _db.Identifiers ct); } - _db.Identifiers.Add(projection); + DbSet.Add(projection); await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); @@ -178,7 +180,7 @@ public async Task SaveAsync(UserIdentifier entity, long expectedVersion, Cancell if (entity.IsPrimary) { - await _db.Identifiers + await DbSet .Where(x => x.Tenant == _tenant && x.UserKey == entity.UserKey && @@ -191,7 +193,7 @@ await _db.Identifiers ct); } - var existing = await _db.Identifiers + var existing = await DbSet .SingleOrDefaultAsync(x => x.Id == entity.Id && x.Tenant == _tenant, @@ -215,7 +217,7 @@ public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, D { ct.ThrowIfCancellationRequested(); - var projection = await _db.Identifiers + var projection = await DbSet .SingleOrDefaultAsync(x => x.Id == key && x.Tenant == _tenant, @@ -229,7 +231,7 @@ public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, D if (mode == DeleteMode.Hard) { - _db.Identifiers.Remove(projection); + DbSet.Remove(projection); } else { @@ -247,7 +249,7 @@ public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOf if (mode == DeleteMode.Hard) { - await _db.Identifiers + await DbSet .Where(x => x.Tenant == _tenant && x.UserKey == userKey) @@ -256,7 +258,7 @@ await _db.Identifiers return; } - await _db.Identifiers + await DbSet .Where(x => x.Tenant == _tenant && x.UserKey == userKey && @@ -275,7 +277,7 @@ public async Task> GetByUsersAsync(IReadOnlyList(); - var projections = await _db.Identifiers + var projections = await DbSet .AsNoTracking() .Where(x => x.Tenant == _tenant && @@ -299,7 +301,7 @@ public async Task> QueryAsync(UserIdentifierQuery qu var normalized = query.Normalize(); - var baseQuery = _db.Identifiers + var baseQuery = DbSet .AsNoTracking() .Where(x => x.Tenant == _tenant && diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs index fe6cdbdf..2d343b42 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs @@ -4,17 +4,17 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class EfCoreUserIdentifierStoreFactory : IUserIdentifierStoreFactory +internal sealed class EfCoreUserIdentifierStoreFactory : IUserIdentifierStoreFactory where TDbContext : DbContext { - private readonly UAuthUserDbContext _db; + private readonly TDbContext _db; - public EfCoreUserIdentifierStoreFactory(UAuthUserDbContext db) + public EfCoreUserIdentifierStoreFactory(TDbContext db) { _db = db; } public IUserIdentifierStore Create(TenantKey tenant) { - return new EfCoreUserIdentifierStore(_db, new TenantContext(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 index eea3a8c8..63c8ef35 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -6,22 +6,24 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class EfCoreUserLifecycleStore : IUserLifecycleStore +internal sealed class EfCoreUserLifecycleStore : IUserLifecycleStore where TDbContext : DbContext { - private readonly UAuthUserDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreUserLifecycleStore(UAuthUserDbContext db, TenantContext 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 _db.Lifecycles + var projection = await DbSet .AsNoTracking() .SingleOrDefaultAsync( x => x.Tenant == _tenant && @@ -35,7 +37,7 @@ public async Task ExistsAsync(UserLifecycleKey key, CancellationToken ct = { ct.ThrowIfCancellationRequested(); - return await _db.Lifecycles + return await DbSet .AnyAsync( x => x.Tenant == _tenant && x.UserKey == key.UserKey, @@ -51,7 +53,7 @@ public async Task AddAsync(UserLifecycle entity, CancellationToken ct = default) var projection = entity.ToProjection(); - _db.Lifecycles.Add(projection); + DbSet.Add(projection); await _db.SaveChangesAsync(ct); } @@ -60,7 +62,7 @@ public async Task SaveAsync(UserLifecycle entity, long expectedVersion, Cancella { ct.ThrowIfCancellationRequested(); - var existing = await _db.Lifecycles + var existing = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == entity.UserKey, @@ -82,7 +84,7 @@ public async Task DeleteAsync(UserLifecycleKey key, long expectedVersion, Delete { ct.ThrowIfCancellationRequested(); - var projection = await _db.Lifecycles + var projection = await DbSet .SingleOrDefaultAsync( x => x.Tenant == _tenant && x.UserKey == key.UserKey, @@ -96,7 +98,7 @@ public async Task DeleteAsync(UserLifecycleKey key, long expectedVersion, Delete if (mode == DeleteMode.Hard) { - _db.Lifecycles.Remove(projection); + DbSet.Remove(projection); } else { @@ -113,7 +115,7 @@ public async Task> QueryAsync(UserLifecycleQuery quer var normalized = query.Normalize(); - var baseQuery = _db.Lifecycles + var baseQuery = DbSet .AsNoTracking() .Where(x => x.Tenant == _tenant); diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs index 9a346c7a..7e5c4d44 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs @@ -4,17 +4,17 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class EfCoreUserLifecycleStoreFactory : IUserLifecycleStoreFactory +internal sealed class EfCoreUserLifecycleStoreFactory : IUserLifecycleStoreFactory where TDbContext : DbContext { - private readonly UAuthUserDbContext _db; + private readonly TDbContext _db; - public EfCoreUserLifecycleStoreFactory(UAuthUserDbContext db) + public EfCoreUserLifecycleStoreFactory(TDbContext db) { _db = db; } public IUserLifecycleStore Create(TenantKey tenant) { - return new EfCoreUserLifecycleStore(_db, new TenantContext(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 index a3772ddb..9623dc92 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -7,22 +7,24 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; -internal sealed class EfCoreUserProfileStore : IUserProfileStore +internal sealed class EfCoreUserProfileStore : IUserProfileStore where TDbContext : DbContext { - private readonly UAuthUserDbContext _db; + private readonly TDbContext _db; private readonly TenantKey _tenant; - public EfCoreUserProfileStore(UAuthUserDbContext db, TenantContext 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 _db.Profiles + var projection = await DbSet .AsNoTracking() .SingleOrDefaultAsync(x => x.Tenant == _tenant && @@ -36,7 +38,7 @@ public async Task ExistsAsync(UserProfileKey key, CancellationToken ct = d { ct.ThrowIfCancellationRequested(); - return await _db.Profiles + return await DbSet .AnyAsync(x => x.Tenant == _tenant && x.UserKey == key.UserKey, @@ -52,7 +54,7 @@ public async Task AddAsync(UserProfile entity, CancellationToken ct = default) if (entity.Version != 0) throw new InvalidOperationException("New profile must have version 0."); - _db.Profiles.Add(projection); + DbSet.Add(projection); await _db.SaveChangesAsync(ct); } @@ -61,7 +63,7 @@ public async Task SaveAsync(UserProfile entity, long expectedVersion, Cancellati { ct.ThrowIfCancellationRequested(); - var existing = await _db.Profiles + var existing = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == entity.UserKey, @@ -83,7 +85,7 @@ public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMo { ct.ThrowIfCancellationRequested(); - var projection = await _db.Profiles + var projection = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == key.UserKey, @@ -97,7 +99,7 @@ public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMo if (mode == DeleteMode.Hard) { - _db.Profiles.Remove(projection); + DbSet.Remove(projection); } else { @@ -114,7 +116,7 @@ public async Task> QueryAsync(UserProfileQuery query, C var normalized = query.Normalize(); - var baseQuery = _db.Profiles + var baseQuery = DbSet .AsNoTracking() .Where(x => x.Tenant == _tenant); @@ -166,7 +168,7 @@ public async Task> GetByUsersAsync(IReadOnlyList x.Tenant == _tenant) .Where(x => userKeys.Contains(x.UserKey)) diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index 3d585691..72310f36 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -14,10 +14,6 @@ public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.TryAddSingleton, InMemoryUserIdProvider>(); - - // Seed never try add - services.AddSingleton(); return services; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs index aca9fa48..e2acf3aa 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +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; @@ -6,6 +8,7 @@ using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Moq; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit; 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..21f57769 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -0,0 +1,295 @@ +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 FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthFlowClientTests +{ + 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(); + } +} \ 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/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 073f0b56..465c2fc0 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -19,8 +19,9 @@ - + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs index 9144d984..35d5a069 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs @@ -22,7 +22,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -50,13 +50,13 @@ public async Task Update_With_RegisterFailure_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + 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 store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantContext(tenant)); var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); var updated = existing!.RegisterFailure( @@ -69,7 +69,7 @@ public async Task Update_With_RegisterFailure_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); Assert.Equal(1, result!.SecurityVersion); @@ -84,7 +84,7 @@ public async Task Update_With_Wrong_Version_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); @@ -107,13 +107,13 @@ public async Task RegisterSuccess_Should_Clear_Failures() await using (var db1 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + 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 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); @@ -121,7 +121,7 @@ public async Task RegisterSuccess_Should_Clear_Failures() await using (var db3 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); Assert.Equal(0, result!.FailedAttempts); @@ -140,7 +140,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db1 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); await store.AddAsync(state); } @@ -148,7 +148,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db2 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantContext(tenant)); + 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); @@ -156,7 +156,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db3 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + 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); @@ -164,7 +164,7 @@ public async Task BeginReset_And_Consume_Should_Work() await using (var db4 = CreateDb(connection)) { - var store = new EfCoreAuthenticationSecurityStateStore(db4, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db4, new TenantContext(tenant)); var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); Assert.NotNull(result!.ResetConsumedAt); } @@ -177,7 +177,7 @@ public async Task Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -201,8 +201,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() 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 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); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs index 36f0a519..23e651d1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs @@ -24,7 +24,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -52,7 +52,7 @@ public async Task Exists_Should_Return_True_When_Exists() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -90,13 +90,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); + 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 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); @@ -104,7 +104,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); + var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); Assert.Equal(1, result!.Version); @@ -131,13 +131,13 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); + 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 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); @@ -156,8 +156,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() 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 store1 = new EfCorePasswordCredentialStore(db, new TenantContext(tenant1)); + var store2 = new EfCorePasswordCredentialStore(db, new TenantContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -183,7 +183,7 @@ public async Task Soft_Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -229,13 +229,13 @@ public async Task Revoke_Should_Persist() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); + 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 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); @@ -243,7 +243,7 @@ public async Task Revoke_Should_Persist() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); + var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); Assert.True(result!.IsRevoked); @@ -269,13 +269,13 @@ public async Task ChangeSecret_Should_Update_SecurityState() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); + 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 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); @@ -284,7 +284,7 @@ public async Task ChangeSecret_Should_Update_SecurityState() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); + var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); Assert.Equal("new_hash", result!.SecretHash); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs index 0332e7f6..8418e86c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs @@ -24,7 +24,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); var role = Role.Create( null, @@ -49,7 +49,7 @@ public async Task Add_With_Duplicate_Name_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + 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); @@ -69,7 +69,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + 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); @@ -77,7 +77,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + 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); @@ -85,7 +85,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.Equal(1, result!.Version); @@ -103,7 +103,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + 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); @@ -111,7 +111,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); var existing = await store.GetAsync(new RoleKey(tenant, roleId)); var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); @@ -131,7 +131,7 @@ public async Task Rename_To_Existing_Name_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + 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; @@ -142,7 +142,7 @@ public async Task Rename_To_Existing_Name_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); var role = await store.GetAsync(new RoleKey(tenant, role2Id)); var updated = role!.Rename("admin", DateTimeOffset.UtcNow); @@ -160,7 +160,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); var role = Role.Create( null, @@ -176,7 +176,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); var existing = await store.GetAsync(new RoleKey(tenant, roleId)); var updated = existing!.SetPermissions( new[] @@ -189,7 +189,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.Single(result!.Permissions); @@ -207,7 +207,7 @@ public async Task Soft_Delete_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + 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); @@ -215,13 +215,13 @@ public async Task Soft_Delete_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + 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 store = new EfCoreRoleStore(db, new TenantContext(tenant)); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.NotNull(result!.DeletedAt); } @@ -234,7 +234,7 @@ public async Task Query_Should_Filter_And_Page() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + 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)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs index 3970a4ed..ac3d32c5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs @@ -24,7 +24,7 @@ public async Task Create_And_Get_Session_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -90,7 +90,7 @@ public async Task Session_Should_Persist_DeviceContext() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); @@ -127,7 +127,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var result = await store.GetSessionAsync(sessionId); @@ -167,7 +167,7 @@ public async Task Session_Should_Persist_Claims_And_Metadata() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); @@ -204,7 +204,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var result = await store.GetSessionAsync(sessionId); @@ -234,7 +234,7 @@ public async Task Revoke_Session_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); @@ -288,7 +288,7 @@ public async Task Should_Not_See_Session_From_Other_Tenant() await using (var db = CreateDb(connection)) { - var store1 = new EfCoreSessionStore(db, new TenantContext(tenant1)); + var store1 = new EfCoreSessionStore(db, new TenantContext(tenant1)); var root = UAuthSessionRoot.Create(tenant1, userKey, DateTimeOffset.UtcNow); @@ -325,7 +325,7 @@ await store1.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store2 = new EfCoreSessionStore(db, new TenantContext(tenant2)); + var store2 = new EfCoreSessionStore(db, new TenantContext(tenant2)); var result = await store2.GetSessionAsync(sessionId); @@ -343,7 +343,7 @@ public async Task ExecuteAsync_Should_Rollback_On_Error() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); await Assert.ThrowsAsync(async () => { @@ -368,7 +368,7 @@ public async Task GetSessionsByChain_Should_Return_Sessions() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -405,7 +405,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var sessions = await store.GetSessionsByChainAsync(chainId); Assert.Single(sessions); } @@ -423,7 +423,7 @@ public async Task ExecuteAsync_Should_Commit_Multiple_Operations() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); await store.ExecuteAsync(async ct => { @@ -460,7 +460,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var result = await store.GetSessionAsync(sessionId); @@ -480,7 +480,7 @@ public async Task ExecuteAsync_Should_Rollback_All_On_Failure() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); await Assert.ThrowsAsync(async () => { @@ -516,7 +516,7 @@ public async Task RevokeChainCascade_Should_Revoke_All_Sessions() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -554,7 +554,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); await store.ExecuteAsync(async ct => { @@ -564,7 +564,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var sessions = await store.GetSessionsByChainAsync(chainId); Assert.All(sessions, s => Assert.True(s.IsRevoked)); @@ -584,7 +584,7 @@ public async Task SetActiveSession_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); @@ -612,7 +612,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var active = await store.GetActiveSessionIdAsync(chainId); Assert.Equal(sessionId, active); @@ -648,7 +648,7 @@ public async Task SaveSession_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -686,7 +686,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); await store.ExecuteAsync(async ct => { @@ -699,7 +699,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var result = await store.GetSessionAsync(sessionId); Assert.Equal(1, result!.Version); @@ -719,7 +719,7 @@ public async Task SaveSession_With_Wrong_Version_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -757,7 +757,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); await Assert.ThrowsAsync(async () => { @@ -784,7 +784,7 @@ public async Task SaveChain_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); chainId = SessionChainId.New(); @@ -809,7 +809,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); await store.ExecuteAsync(async ct => { @@ -822,7 +822,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var result = await store.GetChainAsync(chainId); Assert.Equal(1, result!.Version); @@ -839,7 +839,7 @@ public async Task SaveRoot_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); var root = UAuthSessionRoot.Create( tenant, @@ -854,7 +854,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); await store.ExecuteAsync(async ct => { @@ -867,7 +867,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + 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 index 58e608a9..c7bd8179 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs @@ -22,7 +22,7 @@ public async Task Store_And_Find_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); AuthSessionId.TryCreate(ValidRaw, out var sessionId); var token = RefreshToken.Create( @@ -58,7 +58,7 @@ public async Task Revoke_Should_Set_RevokedAt() await using (var db = CreateDb(connection)) { - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); var token = RefreshToken.Create( TokenId.From(Guid.NewGuid()), @@ -79,7 +79,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); await store.ExecuteAsync(async ct => { @@ -89,7 +89,7 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); var result = await store.FindByHashAsync(tokenHash); Assert.NotNull(result!.RevokedAt); @@ -103,7 +103,7 @@ public async Task Store_Outside_Transaction_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); AuthSessionId.TryCreate(ValidRaw, out var sessionId); var token = RefreshToken.Create( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs index 751f24f2..4fd806af 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs @@ -23,7 +23,7 @@ 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 store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( @@ -49,7 +49,7 @@ 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 store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( @@ -77,7 +77,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() var userKey = UserKey.FromGuid(Guid.NewGuid()); await using var db1 = CreateDb(connection); - var store1 = new EfCoreUserIdentifierStore(db1, new TenantContext(tenant)); + var store1 = new EfCoreUserIdentifierStore(db1, new TenantContext(tenant)); var identifier = UserIdentifier.Create( Guid.NewGuid(), @@ -92,7 +92,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await store1.AddAsync(identifier); await using var db2 = CreateDb(connection); - var store2 = new EfCoreUserIdentifierStore(db2, new TenantContext(tenant)); + var store2 = new EfCoreUserIdentifierStore(db2, new TenantContext(tenant)); var updated = identifier.SetPrimary(DateTimeOffset.UtcNow); @@ -120,13 +120,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserIdentifierStore(db1, new TenantContext(tenant)); + 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 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); @@ -134,7 +134,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserIdentifierStore(db3, new TenantContext(tenant)); + var store3 = new EfCoreUserIdentifierStore(db3, new TenantContext(tenant)); var result = await store3.GetAsync(identifier.Id); Assert.Equal(1, result!.Version); } @@ -147,8 +147,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() 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 store1 = new EfCoreUserIdentifierStore(db, new TenantContext(tenant1)); + var store2 = new EfCoreUserIdentifierStore(db, new TenantContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -174,7 +174,7 @@ 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 store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs index 4c08b7b3..0c7c0333 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs @@ -23,7 +23,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -48,7 +48,7 @@ public async Task Exists_Should_Return_True_When_Exists() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -78,13 +78,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + 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 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); @@ -92,7 +92,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserLifecycleStore(db3, new TenantContext(tenant)); + var store3 = new EfCoreUserLifecycleStore(db3, new TenantContext(tenant)); var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); Assert.Equal(1, result!.Version); @@ -115,13 +115,13 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + 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 store2 = new EfCoreUserLifecycleStore(db2, new TenantContext(tenant)); var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); @@ -139,8 +139,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() 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 store1 = new EfCoreUserLifecycleStore(db, new TenantContext(tenant1)); + var store2 = new EfCoreUserLifecycleStore(db, new TenantContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -163,7 +163,7 @@ public async Task Soft_Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -201,13 +201,13 @@ public async Task Delete_Should_Increment_SecurityVersion() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + 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 store2 = new EfCoreUserLifecycleStore(db2, new TenantContext(tenant)); var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); var deleted = existing!.MarkDeleted(DateTimeOffset.UtcNow); @@ -217,7 +217,7 @@ public async Task Delete_Should_Increment_SecurityVersion() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserLifecycleStore(db3, new TenantContext(tenant)); + var store3 = new EfCoreUserLifecycleStore(db3, new TenantContext(tenant)); var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs index db39136f..87d84078 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs @@ -23,7 +23,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -51,7 +51,7 @@ public async Task Exists_Should_Return_True_When_Exists() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -91,13 +91,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserProfileStore(db1, new TenantContext(tenant)); + 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 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); @@ -105,7 +105,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserProfileStore(db3, new TenantContext(tenant)); + var store3 = new EfCoreUserProfileStore(db3, new TenantContext(tenant)); var result = await store3.GetAsync(new UserProfileKey(tenant, userKey)); Assert.Equal(1, result!.Version); @@ -133,13 +133,13 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserProfileStore(db1, new TenantContext(tenant)); + 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 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); @@ -157,8 +157,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() 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 store1 = new EfCoreUserProfileStore(db, new TenantContext(tenant1)); + var store2 = new EfCoreUserProfileStore(db, new TenantContext(tenant2)); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -185,7 +185,7 @@ public async Task Soft_Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs index f1b5f3b6..e1062e34 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs @@ -22,7 +22,7 @@ public async Task Assign_And_GetAssignments_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); @@ -41,7 +41,7 @@ public async Task Assign_Duplicate_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); @@ -58,7 +58,7 @@ public async Task Remove_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); @@ -77,7 +77,7 @@ public async Task Remove_NonExisting_Should_Not_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); var userKey = UserKey.FromGuid(Guid.NewGuid()); var roleId = RoleId.New(); @@ -92,7 +92,7 @@ public async Task CountAssignments_Should_Return_Correct_Count() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); var roleId = RoleId.New(); @@ -111,7 +111,7 @@ public async Task RemoveAssignmentsByRole_Should_Remove_All() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); var roleId = RoleId.New(); @@ -137,8 +137,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() 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 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(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 38a62c52..a37eb1d7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -8,6 +8,7 @@ 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; @@ -36,6 +37,8 @@ public TestAuthRuntime(Action? configureServer = null, Actio configureServer?.Invoke(options); }); + services.AddUltimateAuthSampleSeed(); + services.AddSingleton(); // InMemory plugins services.AddUltimateAuthInMemory(); 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; } +} From e7649dadb41cd060d9ec8ba36eb2e5f19cf8371b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:44:35 +0300 Subject: [PATCH 41/50] Update Codecov thresholds for coverage metrics --- .github/codecov.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/codecov.yml b/.github/codecov.yml index 72a0fa4e..70dbff6f 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -5,9 +5,9 @@ coverage: status: project: default: - target: 0% - threshold: 0% + target: 50% + threshold: 5% patch: default: - target: 0% + target: 20% threshold: 0% From 0f7c3d93c700fe01e9f12c8dd9b524bbad3cc696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:07:07 +0300 Subject: [PATCH 42/50] Code Review (#28) * Reviewed Credential Client API * Reviewed Authorization Client API * Reviewed Flow Client API * Reviewed User Client API --- README.md | 4 +- .../Dialogs/AccountStatusDialog.razor.cs | 4 +- .../Dialogs/CreateUserDialog.razor.cs | 2 +- .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Dialogs/IdentifierDialog.razor.cs | 64 +++++++++---------- .../Dialogs/PermissionDialog.razor.cs | 5 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Components/Dialogs/RoleDialog.razor.cs | 7 +- .../Components/Dialogs/SessionDialog.razor.cs | 10 +-- .../Components/Dialogs/UserDetailDialog.razor | 4 +- .../Dialogs/UserDetailDialog.razor.cs | 10 +-- .../Dialogs/UserRoleDialog.razor.cs | 16 ++++- .../Components/Dialogs/UsersDialog.razor.cs | 2 +- .../Components/Pages/Home.razor.cs | 4 +- .../Components/Pages/ResetCredential.razor.cs | 2 +- .../Dialogs/AccountStatusDialog.razor.cs | 4 +- .../Dialogs/CreateUserDialog.razor.cs | 2 +- .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Dialogs/IdentifierDialog.razor.cs | 60 ++++++++--------- .../Dialogs/PermissionDialog.razor.cs | 5 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Components/Dialogs/RoleDialog.razor.cs | 7 +- .../Components/Dialogs/SessionDialog.razor.cs | 10 +-- .../Components/Dialogs/UserDetailDialog.razor | 4 +- .../Dialogs/UserDetailDialog.razor.cs | 10 +-- .../Dialogs/UserRoleDialog.razor.cs | 16 ++++- .../Components/Dialogs/UsersDialog.razor.cs | 2 +- .../Components/Pages/Home.razor.cs | 4 +- .../Components/Pages/ResetCredential.razor.cs | 2 +- .../Dialogs/AccountStatusDialog.razor.cs | 4 +- .../Dialogs/CreateUserDialog.razor.cs | 2 +- .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Dialogs/IdentifierDialog.razor.cs | 44 ++++++------- .../Dialogs/PermissionDialog.razor.cs | 5 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Components/Dialogs/RoleDialog.razor.cs | 7 +- .../Components/Dialogs/SessionDialog.razor.cs | 10 +-- .../Components/Dialogs/UserDetailDialog.razor | 4 +- .../Dialogs/UserDetailDialog.razor.cs | 12 ++-- .../Dialogs/UserRoleDialog.razor.cs | 16 ++++- .../Components/Dialogs/UsersDialog.razor.cs | 2 +- .../Pages/Home.razor.cs | 4 +- .../Pages/ResetCredential.razor.cs | 2 +- .../Abstractions/Issuers/ISessionIssuer.cs | 2 +- .../Contracts/Access/AccessScope.cs | 8 +-- .../Contracts/Authority/AuthOperation.cs | 14 ++-- .../Authority/AuthorizationDecision.cs | 6 +- .../Authority/DeviceMismatchBehavior.cs | 6 +- .../Contracts/Common/CaseHandling.cs | 6 +- .../Contracts/Common/DeleteMode.cs | 4 +- .../Contracts/Common/PageRequest.cs | 2 +- .../Contracts/Login/ExternalLoginRequest.cs | 10 +-- .../Contracts/Login/LoginContinuationType.cs | 6 +- .../Contracts/Login/LoginStatus.cs | 6 +- .../Contracts/Login/ReauthRequest.cs | 3 +- .../Contracts/Login/UAuthLoginType.cs | 4 +- .../Contracts/Logout/LogoutAllRequest.cs | 7 +- .../Contracts/Logout/LogoutReason.cs | 11 ++-- .../Contracts/Logout/LogoutRequest.cs | 3 - .../Contracts/Mfa/BeginMfaRequest.cs | 2 +- .../Contracts/Mfa/CompleteMfaRequest.cs | 4 +- .../Contracts/Mfa/MfaChallengeResult.cs | 4 +- .../Contracts/Pkce/PkceAuthorizeCommand.cs | 4 +- .../Contracts/Pkce/PkceAuthorizeResponse.cs | 2 +- .../Contracts/Pkce/PkceCompleteRequest.cs | 14 ++-- .../Contracts/Pkce/TryPkceLoginResult.cs | 2 +- .../Contracts/Refresh/RefreshFlowRequest.cs | 3 +- .../Contracts/Refresh/RefreshStrategy.cs | 10 +-- .../Refresh/RefreshTokenPersistence.cs | 4 +- .../Refresh/RefreshTokenValidationContext.cs | 2 +- .../Contracts/Session/Dtos/AuthSnapshotDto.cs | 8 --- .../Session/Dtos/AuthSnapshotInfo.cs | 8 +++ .../Dtos/{ClaimsDto.cs => ClaimsInfo.cs} | 2 +- ...{SessionIdentityDto.cs => IdentityInfo.cs} | 2 +- ...idationDto.cs => SessionValidationInfo.cs} | 4 +- ...onContext.cs => SessionIssuanceContext.cs} | 2 +- .../Session/SessionRefreshRequest.cs | 9 --- .../Contracts/Session/SessionTouchMode.cs | 4 +- .../Contracts/Token/AuthTokens.cs | 2 +- .../Contracts/Token/PrimaryTokenKind.cs | 4 +- .../Contracts/Token/TokenFormat.cs | 5 +- .../Contracts/Token/TokenInvalidReason.cs | 20 +++--- .../Contracts/Token/TokenIssueContext.cs | 11 ---- .../Contracts/User/UserStatus.cs | 2 +- .../Infrastructure/SessionValidationMapper.cs | 2 +- .../Endpoints/LogoutEndpointHandler.cs | 6 +- .../Endpoints/RefreshEndpointHandler.cs | 5 +- .../Flows/Login/LoginOrchestrator.cs | 2 +- .../Issuers/UAuthSessionIssuer.cs | 2 +- .../Orchestrator/CreateLoginSessionCommand.cs | 2 +- .../ResourceApi/RemoteSessionValidator.cs | 2 +- .../Services/RefreshFlowService.cs | 48 ++++++-------- .../Services/UAuthFlowService.cs | 14 ++-- .../Requests/AssignRoleRequest.cs | 9 ++- .../Requests/AuthorizationCheckRequest.cs | 2 +- .../Requests/CreateRoleRequest.cs | 6 +- .../Requests/DeleteRoleRequest.cs | 5 +- .../Requests/RemoveRoleRequest.cs | 9 +++ .../Requests/RenameRoleRequest.cs | 5 +- .../{Dtos => Requests}/RoleQuery.cs | 2 +- .../Requests/SetPermissionsRequest.cs | 6 -- .../Requests/SetRolePermissionsRequest.cs | 7 ++ .../Endpoints/AuthorizationEndpointHandler.cs | 6 +- ...ispatch.razor => UAuthLoginDispatch.razor} | 0 .../Abstractions/IAuthorizationClient.cs | 10 +-- .../Abstractions/ICredentialClient.cs | 16 ++--- .../Services/Abstractions/IFlowClient.cs | 12 ++-- .../Services/Abstractions/IUserClient.cs | 12 ++-- .../Abstractions/IUserIdentifierClient.cs | 28 ++++---- .../Services/UAuthAuthorizationClient.cs | 26 +++----- .../Services/UAuthCredentialClient.cs | 28 ++++---- .../Services/UAuthFlowClient.cs | 12 ++-- .../Services/UAuthUserClient.cs | 12 ++-- .../Services/UAuthUserIdentifierClient.cs | 28 ++++---- .../Request/AddCredentialRequest.cs | 6 +- .../Request/BeginCredentialResetRequest.cs | 12 ---- .../Request/BeginResetCredentialRequest.cs | 12 ++++ ...t.cs => CompleteResetCredentialRequest.cs} | 4 +- .../Request/CredentialActionRequest.cs | 7 -- .../Request/DeleteCredentialRequest.cs | 4 +- .../Request/ResetPasswordRequest.cs | 2 +- .../Request/RevokeAllCredentialsRequest.cs | 8 --- .../Request/SetInitialCredentialRequest.cs | 17 ----- .../Request/ValidateCredentialsRequest.cs | 11 ---- .../Endpoints/CredentialEndpointHandler.cs | 8 +-- .../Services/CredentialManagementService.cs | 4 +- .../Services/ICredentialManagementService.cs | 4 +- .../Dtos/AdminAssignableUserStatus.cs | 17 +++++ ...rStatus.cs => SelfAssignableUserStatus.cs} | 2 +- .../Dtos/UserQuery.cs | 2 +- .../Mappers/UserStatusMapper.cs | 56 +++++++++++++++- .../Requests/AddUserIdentifierRequest.cs | 2 +- .../Requests/BeginMfaSetupRequest.cs | 6 -- .../Requests/ChangeUserStatusAdminRequest.cs | 8 +-- .../Requests/ChangeUserStatusSelfRequest.cs | 4 +- .../Requests/CompleteMfaSetupRequest.cs | 7 -- .../Requests/DeleteUserIdentifierRequest.cs | 4 +- .../Requests/DeleteUserRequest.cs | 4 +- .../Requests/DisableMfaRequest.cs | 6 -- .../Requests/IdentifierExistsRequest.cs | 6 +- .../Requests/LogoutDeviceRequest.cs | 2 +- .../LogoutOtherDevicesAdminRequest.cs | 8 --- ...equest.cs => LogoutOtherDevicesRequest.cs} | 2 +- .../Requests/RegisterUserRequest.cs | 2 +- .../SetPrimaryUserIdentifierRequest.cs | 2 +- .../UnsetPrimaryUserIdentifierRequest.cs | 2 +- .../Requests/UserIdentifierQuery.cs | 6 +- .../Requests/VerifyUserIdentifierRequest.cs | 2 +- .../Contracts/UserLifecycleQuery.cs | 2 +- .../Contracts/UserProfileQuery.cs | 2 +- .../Services/UserApplicationService.cs | 15 +++-- .../Credentials/ResetPasswordTests.cs | 30 ++++----- .../Fake/FakeFlowClient.cs | 24 ++++++- .../UserIdentifierApplicationServiceTests.cs | 2 +- 157 files changed, 656 insertions(+), 620 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs rename src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/{ClaimsDto.cs => ClaimsInfo.cs} (89%) rename src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/{SessionIdentityDto.cs => IdentityInfo.cs} (86%) rename src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/{SessionValidationDto.cs => SessionValidationInfo.cs} (60%) rename src/CodeBeam.UltimateAuth.Core/Contracts/Session/{AuthenticatedSessionContext.cs => SessionIssuanceContext.cs} (95%) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RemoveRoleRequest.cs rename src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/{Dtos => Requests}/RoleQuery.cs (81%) delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetRolePermissionsRequest.cs rename src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/{UALoginDispatch.razor => UAuthLoginDispatch.razor} (100%) delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginResetCredentialRequest.cs rename src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/{CompleteCredentialResetRequest.cs => CompleteResetCredentialRequest.cs} (64%) delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/AdminAssignableUserStatus.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/{SelfUserStatus.cs => SelfAssignableUserStatus.cs} (72%) delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/{LogoutOtherDevicesSelfRequest.cs => LogoutOtherDevicesRequest.cs} (76%) diff --git a/README.md b/README.md index d1c63fa8..39ee5ee2 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ app.MapRazorComponents() ``` ### 4) Add UAuth Script -Place this in `App.razor` or `index.html` +Place this in `App.razor` or `index.html` in your Blazor client application: ```csharp ``` @@ -281,7 +281,7 @@ LogoutAll But Keep Current Device private async Task LogoutOthersAsync() { - var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); Console.WriteLine(result.IsSuccess); } ``` 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 index edeedaa0..195f1fbd 100644 --- 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 @@ -31,8 +31,8 @@ You can still active your account later. return; } - ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.SelfSuspended }; - var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); if (result.IsSuccess) { Snackbar.Add("Your account suspended successfully.", Severity.Success); 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 index 778ad577..820b1119 100644 --- 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 @@ -37,7 +37,7 @@ private async Task CreateUserAsync() Password = _password }; - var result = await UAuthClient.Users.CreateAdminAsync(request); + var result = await UAuthClient.Users.CreateAsAdminAsync(request); if (!result.IsSuccess) { 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 index f9829141..5f419abf 100644 --- 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 @@ -69,7 +69,7 @@ private async Task ChangePasswordAsync() } else { - result = await UAuthClient.Credentials.ChangeCredentialAsync(UserKey.Value, request); + result = await UAuthClient.Credentials.ChangeUserAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index c07885b3..67a76cff 100644 --- 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 @@ -31,7 +31,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { - var result = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + var result = await UAuthClient.Identifiers.GetMyAsync(); if (result != null && result.IsSuccess && result.Value != null) { await ReloadAsync(); @@ -56,11 +56,11 @@ private async Task> LoadServerData(GridState CommittedItemChanges(UserIdentifierIn if (UserKey is null) { - result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); + result = await UAuthClient.Identifiers.UpdateMyAsync(updateRequest); } else { - result = await UAuthClient.Identifiers.UpdateAdminAsync(UserKey.Value, updateRequest); + result = await UAuthClient.Identifiers.UpdateUserAsync(UserKey.Value, updateRequest); } if (result.IsSuccess) { - Snackbar.Add("Identifier updated successfully", Severity.Success); + Snackbar.Add("Identifier updated successfully.", Severity.Success); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to update identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to update identifier.", Severity.Error); } await ReloadAsync(); @@ -165,29 +165,29 @@ private async Task AddNewIdentifier() if (UserKey is null) { - result = await UAuthClient.Identifiers.AddSelfAsync(request); + result = await UAuthClient.Identifiers.AddMyAsync(request); } else { - result = await UAuthClient.Identifiers.AddAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.AddUserAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Identifier added successfully", Severity.Success); + Snackbar.Add("Identifier added successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to add identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to add identifier.", Severity.Error); } } private async Task VerifyAsync(Guid id) { var demoInfo = await DialogService.ShowMessageBoxAsync( - title: "Demo verification", + title: "Demo Verification", markupMessage: (MarkupString) """ This is a demo action.

@@ -203,105 +203,105 @@ This will only mark the identifier as verified in UltimateAuth. return; } - VerifyUserIdentifierRequest request = new() { IdentifierId = id }; + VerifyUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.VerifySelfAsync(request); + result = await UAuthClient.Identifiers.VerifyMyAsync(request); } else { - result = await UAuthClient.Identifiers.VerifyAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.VerifyUserAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Identifier verified successfully", Severity.Success); + Snackbar.Add("Identifier verified successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier.", Severity.Error); } } private async Task SetPrimaryAsync(Guid id) { - SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + SetPrimaryUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + result = await UAuthClient.Identifiers.SetMyPrimaryAsync(request); } else { - result = await UAuthClient.Identifiers.SetPrimaryAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.SetUserPrimaryAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Primary identifier set successfully", Severity.Success); + Snackbar.Add("Primary identifier set successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier.", Severity.Error); } } private async Task UnsetPrimaryAsync(Guid id) { - UnsetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + UnsetPrimaryUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(request); } else { - result = await UAuthClient.Identifiers.UnsetPrimaryAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Primary identifier unset successfully", Severity.Success); + Snackbar.Add("Primary identifier unset successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier.", Severity.Error); } } private async Task DeleteIdentifier(Guid id) { - DeleteUserIdentifierRequest request = new() { IdentifierId = id }; + DeleteUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + result = await UAuthClient.Identifiers.DeleteMyAsync(request); } else { - result = await UAuthClient.Identifiers.DeleteAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.DeleteUserAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Identifier deleted successfully", Severity.Success); + Snackbar.Add("Identifier deleted successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to delete identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to delete identifier.", Severity.Error); } } 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 index 99c3b75b..0e755f76 100644 --- 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 @@ -63,12 +63,13 @@ 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 SetPermissionsRequest + var req = new SetRolePermissionsRequest { + RoleId = Role.Id, Permissions = permissions }; - var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + var result = await UAuthClient.Authorization.SetRolePermissionsAsync(req); if (!result.IsSuccess) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs index 868c9c05..955e8e98 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs @@ -39,7 +39,7 @@ protected override async Task OnInitializedAsync() } else { - result = await UAuthClient.Users.GetProfileAsync(UserKey.Value); + result = await UAuthClient.Users.GetUserAsync(UserKey.Value); } if (result.IsSuccess && result.Value is not null) @@ -96,7 +96,7 @@ private async Task SaveAsync() } else { - result = await UAuthClient.Users.UpdateProfileAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index 55f5195c..a819f039 100644 --- 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 @@ -20,7 +20,7 @@ public partial class ResetDialog private async Task RequestResetAsync() { - var request = new BeginCredentialResetRequest + var request = new BeginResetCredentialRequest { CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Code, 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 index 349ef670..931687d8 100644 --- 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 @@ -53,10 +53,11 @@ private async Task CommittedItemChanges(RoleInfo role) { var req = new RenameRoleRequest { + Id = role.Id, Name = role.Name }; - var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, req); + var result = await UAuthClient.Authorization.RenameRoleAsync(req); if (result.IsSuccess) { @@ -109,8 +110,8 @@ private async Task DeleteRole(RoleId roleId) if (confirm != true) return; - var req = new DeleteRoleRequest(); - var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, req); + var req = new DeleteRoleRequest() { Id = roleId }; + var result = await UAuthClient.Authorization.DeleteRoleAsync(req); if (result.IsSuccess) { 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 index 5ec8d396..fb86d0cf 100644 --- 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 @@ -118,11 +118,11 @@ private async Task LogoutAllAsync() if (UserKey is null) { - result = await UAuthClient.Flows.LogoutAllDevicesSelfAsync(); + result = await UAuthClient.Flows.LogoutAllMyDevicesAsync(); } else { - result = await UAuthClient.Flows.LogoutAllDevicesAdminAsync(UserKey.Value); + result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(UserKey.Value); } if (result.IsSuccess) @@ -139,7 +139,7 @@ private async Task LogoutAllAsync() private async Task LogoutOthersAsync() { - var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); if (result.IsSuccess) { @@ -158,11 +158,11 @@ private async Task LogoutDeviceAsync(SessionChainId chainId) if (UserKey is null) { - result = await UAuthClient.Flows.LogoutDeviceSelfAsync(request); + result = await UAuthClient.Flows.LogoutMyDeviceAsync(request); } else { - result = await UAuthClient.Flows.LogoutDeviceAdminAsync(UserKey.Value, request); + result = await UAuthClient.Flows.LogoutUserDeviceAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index 28bb738d..73fafa48 100644 --- 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 @@ -44,8 +44,8 @@ Status @_user?.Status - - @foreach (var s in Enum.GetValues()) + + @foreach (var s in Enum.GetValues()) { @s } 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 index e5bd3a08..8a1a76a3 100644 --- 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 @@ -11,7 +11,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; public partial class UserDetailDialog { private UserView? _user; - private UserStatus _status; + private AdminAssignableUserStatus _status; [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; @@ -25,12 +25,12 @@ public partial class UserDetailDialog protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - var result = await UAuthClient.Users.GetProfileAsync(UserKey); + var result = await UAuthClient.Users.GetUserAsync(UserKey); if (result.IsSuccess) { _user = result.Value; - _status = _user?.Status ?? UserStatus.Unknown; + _status = _user?.Status.ToAdminAssignableUserStatus() ?? AdminAssignableUserStatus.Unknown; } } @@ -69,12 +69,12 @@ private async Task ChangeStatusAsync() NewStatus = _status }; - var result = await UAuthClient.Users.ChangeStatusAdminAsync(_user.UserKey, request); + var result = await UAuthClient.Users.ChangeUserStatusAsync(_user.UserKey, request); if (result.IsSuccess) { Snackbar.Add("User status updated", Severity.Success); - _user = _user with { Status = _status }; + _user = _user with { Status = _status.ToUserStatus() }; } else { 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 index 94ec688a..b1693a25 100644 --- 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 @@ -49,7 +49,13 @@ private async Task AddRole() if (string.IsNullOrWhiteSpace(_selectedRole)) return; - var result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); + var request = new AssignRoleRequest + { + UserKey = UserKey, + RoleName = _selectedRole + }; + + var result = await UAuthClient.Authorization.AssignRoleToUserAsync(request); if (result.IsSuccess) { @@ -95,7 +101,13 @@ private async Task RemoveRole(string role) } } - var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); + var request = new RemoveRoleRequest + { + UserKey = UserKey, + RoleName = role + }; + + var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(request); if (result.IsSuccess) { 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 index 2344bdf8..e6807ab6 100644 --- 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 @@ -36,7 +36,7 @@ private async Task> LoadUsers(GridState state Descending = sort?.Descending ?? false }; - var res = await UAuthClient.Users.QueryUsersAsync(req); + var res = await UAuthClient.Users.QueryAsync(req); if (!res.IsSuccess || res.Value == null) { 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 index c5d1da90..ab0018b8 100644 --- 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 @@ -200,8 +200,8 @@ private DialogParameters GetDialogParameters() private async Task SetAccountActiveAsync() { - ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.Active }; - var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.Active }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); if (result.IsSuccess) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs index 3bdd9f68..21fad18a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs @@ -25,7 +25,7 @@ private async Task ResetPasswordAsync() return; } - var request = new CompleteCredentialResetRequest + var request = new CompleteResetCredentialRequest { ResetToken = _code, NewSecret = _newPassword ?? string.Empty, 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 index 2981e9c5..4fda31ea 100644 --- 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 @@ -31,8 +31,8 @@ You can still active your account later. return; } - ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.SelfSuspended }; - var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); if (result.IsSuccess) { Snackbar.Add("Your account suspended successfully.", Severity.Success); 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 index 5986ae68..ec16e78f 100644 --- 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 @@ -37,7 +37,7 @@ private async Task CreateUserAsync() Password = _password }; - var result = await UAuthClient.Users.CreateAdminAsync(request); + var result = await UAuthClient.Users.CreateAsAdminAsync(request); if (!result.IsSuccess) { 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 index f81d2b1b..ee48c215 100644 --- 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 @@ -69,7 +69,7 @@ private async Task ChangePasswordAsync() } else { - result = await UAuthClient.Credentials.ChangeCredentialAsync(UserKey.Value, request); + result = await UAuthClient.Credentials.ChangeUserAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index e14d8671..1a2f0a76 100644 --- 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 @@ -31,7 +31,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { - var result = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + var result = await UAuthClient.Identifiers.GetMyAsync(); if (result != null && result.IsSuccess && result.Value != null) { await ReloadAsync(); @@ -56,11 +56,11 @@ private async Task> LoadServerData(GridState CommittedItemChanges(UserIdentifierIn if (UserKey is null) { - result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); + result = await UAuthClient.Identifiers.UpdateMyAsync(updateRequest); } else { - result = await UAuthClient.Identifiers.UpdateAdminAsync(UserKey.Value, updateRequest); + result = await UAuthClient.Identifiers.UpdateUserAsync(UserKey.Value, updateRequest); } if (result.IsSuccess) { - Snackbar.Add("Identifier updated successfully", Severity.Success); + Snackbar.Add("Identifier updated successfully.", Severity.Success); } else { @@ -165,29 +165,29 @@ private async Task AddNewIdentifier() if (UserKey is null) { - result = await UAuthClient.Identifiers.AddSelfAsync(request); + result = await UAuthClient.Identifiers.AddMyAsync(request); } else { - result = await UAuthClient.Identifiers.AddAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.AddUserAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Identifier added successfully", Severity.Success); + Snackbar.Add("Identifier added successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to add identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to add identifier.", Severity.Error); } } private async Task VerifyAsync(Guid id) { var demoInfo = await DialogService.ShowMessageBoxAsync( - title: "Demo verification", + title: "Demo Verification", markupMessage: (MarkupString) """ This is a demo action.

@@ -203,99 +203,99 @@ This will only mark the identifier as verified in UltimateAuth. return; } - VerifyUserIdentifierRequest request = new() { IdentifierId = id }; + VerifyUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.VerifySelfAsync(request); + result = await UAuthClient.Identifiers.VerifyMyAsync(request); } else { - result = await UAuthClient.Identifiers.VerifyAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.VerifyUserAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Identifier verified successfully", Severity.Success); + Snackbar.Add("Identifier verified successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier.", Severity.Error); } } private async Task SetPrimaryAsync(Guid id) { - SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + SetPrimaryUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + result = await UAuthClient.Identifiers.SetMyPrimaryAsync(request); } else { - result = await UAuthClient.Identifiers.SetPrimaryAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.SetUserPrimaryAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Primary identifier set successfully", Severity.Success); + Snackbar.Add("Primary identifier set successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier.", Severity.Error); } } private async Task UnsetPrimaryAsync(Guid id) { - UnsetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + UnsetPrimaryUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(request); } else { - result = await UAuthClient.Identifiers.UnsetPrimaryAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Primary identifier unset successfully", Severity.Success); + Snackbar.Add("Primary identifier unset successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier", Severity.Error); + Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier.", Severity.Error); } } private async Task DeleteIdentifier(Guid id) { - DeleteUserIdentifierRequest request = new() { IdentifierId = id }; + DeleteUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + result = await UAuthClient.Identifiers.DeleteMyAsync(request); } else { - result = await UAuthClient.Identifiers.DeleteAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.DeleteUserAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Identifier deleted successfully", Severity.Success); + Snackbar.Add("Identifier deleted successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } 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 index 16cf08fd..3f085402 100644 --- 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 @@ -63,12 +63,13 @@ 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 SetPermissionsRequest + var req = new SetRolePermissionsRequest { + RoleId = Role.Id, Permissions = permissions }; - var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + var result = await UAuthClient.Authorization.SetRolePermissionsAsync(req); if (!result.IsSuccess) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs index 364a3eb3..24fd603e 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs @@ -39,7 +39,7 @@ protected override async Task OnInitializedAsync() } else { - result = await UAuthClient.Users.GetProfileAsync(UserKey.Value); + result = await UAuthClient.Users.GetUserAsync(UserKey.Value); } if (result.IsSuccess && result.Value is not null) @@ -96,7 +96,7 @@ private async Task SaveAsync() } else { - result = await UAuthClient.Users.UpdateProfileAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index c66f8adc..9ca66fc3 100644 --- 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 @@ -20,7 +20,7 @@ public partial class ResetDialog private async Task RequestResetAsync() { - var request = new BeginCredentialResetRequest + var request = new BeginResetCredentialRequest { CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Code, 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 index 75a673ad..453acb8c 100644 --- 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 @@ -53,10 +53,11 @@ private async Task CommittedItemChanges(RoleInfo role) { var req = new RenameRoleRequest { + Id = role.Id, Name = role.Name }; - var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, req); + var result = await UAuthClient.Authorization.RenameRoleAsync(req); if (result.IsSuccess) { @@ -109,8 +110,8 @@ private async Task DeleteRole(RoleId roleId) if (confirm != true) return; - var req = new DeleteRoleRequest(); - var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, req); + var req = new DeleteRoleRequest() { Id = roleId }; + var result = await UAuthClient.Authorization.DeleteRoleAsync(req); if (result.IsSuccess) { 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 index f5d5822f..17af4337 100644 --- 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 @@ -118,11 +118,11 @@ private async Task LogoutAllAsync() if (UserKey is null) { - result = await UAuthClient.Flows.LogoutAllDevicesSelfAsync(); + result = await UAuthClient.Flows.LogoutAllMyDevicesAsync(); } else { - result = await UAuthClient.Flows.LogoutAllDevicesAdminAsync(UserKey.Value); + result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(UserKey.Value); } if (result.IsSuccess) @@ -139,7 +139,7 @@ private async Task LogoutAllAsync() private async Task LogoutOthersAsync() { - var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); if (result.IsSuccess) { @@ -158,11 +158,11 @@ private async Task LogoutDeviceAsync(SessionChainId chainId) if (UserKey is null) { - result = await UAuthClient.Flows.LogoutDeviceSelfAsync(request); + result = await UAuthClient.Flows.LogoutMyDeviceAsync(request); } else { - result = await UAuthClient.Flows.LogoutDeviceAdminAsync(UserKey.Value, request); + result = await UAuthClient.Flows.LogoutUserDeviceAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index 4cd64ff5..52b9c527 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor @@ -44,8 +44,8 @@ Status @_user?.Status - - @foreach (var s in Enum.GetValues()) + + @foreach (var s in Enum.GetValues()) { @s } 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 index 0e131d3b..7228a3d2 100644 --- 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 @@ -11,7 +11,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; public partial class UserDetailDialog { private UserView? _user; - private UserStatus _status; + private AdminAssignableUserStatus _status; [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; @@ -25,12 +25,12 @@ public partial class UserDetailDialog protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - var result = await UAuthClient.Users.GetProfileAsync(UserKey); + var result = await UAuthClient.Users.GetUserAsync(UserKey); if (result.IsSuccess) { _user = result.Value; - _status = _user?.Status ?? UserStatus.Unknown; + _status = _user?.Status.ToAdminAssignableUserStatus() ?? AdminAssignableUserStatus.Unknown; } } @@ -69,12 +69,12 @@ private async Task ChangeStatusAsync() NewStatus = _status }; - var result = await UAuthClient.Users.ChangeStatusAdminAsync(_user.UserKey, request); + var result = await UAuthClient.Users.ChangeUserStatusAsync(_user.UserKey, request); if (result.IsSuccess) { Snackbar.Add("User status updated", Severity.Success); - _user = _user with { Status = _status }; + _user = _user with { Status = _status.ToUserStatus() }; } else { 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 index ac927232..21683f5b 100644 --- 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 @@ -49,7 +49,13 @@ private async Task AddRole() if (string.IsNullOrWhiteSpace(_selectedRole)) return; - var result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); + var request = new AssignRoleRequest + { + UserKey = UserKey, + RoleName = _selectedRole + }; + + var result = await UAuthClient.Authorization.AssignRoleToUserAsync(request); if (result.IsSuccess) { @@ -95,7 +101,13 @@ private async Task RemoveRole(string role) } } - var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); + var request = new RemoveRoleRequest + { + UserKey = UserKey, + RoleName = role + }; + + var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(request); if (result.IsSuccess) { 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 index 7fc91cc2..3168085d 100644 --- 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 @@ -36,7 +36,7 @@ private async Task> LoadUsers(GridState state Descending = sort?.Descending ?? false }; - var res = await UAuthClient.Users.QueryUsersAsync(req); + var res = await UAuthClient.Users.QueryAsync(req); if (!res.IsSuccess || res.Value == null) { 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 index b25944b1..3faeca19 100644 --- 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 @@ -200,8 +200,8 @@ private DialogParameters GetDialogParameters() private async Task SetAccountActiveAsync() { - ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.Active }; - var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.Active }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); if (result.IsSuccess) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs index fd66181e..db40becc 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs @@ -25,7 +25,7 @@ private async Task ResetPasswordAsync() return; } - var request = new CompleteCredentialResetRequest + var request = new CompleteResetCredentialRequest { ResetToken = _code, NewSecret = _newPassword ?? string.Empty, 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 index a4f18940..0bd13951 100644 --- 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 @@ -31,8 +31,8 @@ You can still active your account later. return; } - ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.SelfSuspended }; - var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); if (result.IsSuccess) { Snackbar.Add("Your account suspended successfully.", Severity.Success); 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 index a1ac5b5c..61aa56fb 100644 --- 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 @@ -37,7 +37,7 @@ private async Task CreateUserAsync() Password = _password }; - var result = await UAuthClient.Users.CreateAdminAsync(request); + var result = await UAuthClient.Users.CreateAsAdminAsync(request); if (!result.IsSuccess) { 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 index 79568ae4..926eba3d 100644 --- 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 @@ -69,7 +69,7 @@ private async Task ChangePasswordAsync() } else { - result = await UAuthClient.Credentials.ChangeCredentialAsync(UserKey.Value, request); + result = await UAuthClient.Credentials.ChangeUserAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index 3ba6b5a4..d0e89b89 100644 --- 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 @@ -33,7 +33,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { _loaded = true; - var result = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + var result = await UAuthClient.Identifiers.GetMyAsync(); if (result != null && result.IsSuccess && result.Value != null) { await ReloadAsync(); @@ -58,11 +58,11 @@ private async Task> LoadServerData(GridState CommittedItemChanges(UserIdentifierIn if (UserKey is null) { - result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); + result = await UAuthClient.Identifiers.UpdateMyAsync(updateRequest); } else { - result = await UAuthClient.Identifiers.UpdateAdminAsync(UserKey.Value, updateRequest); + result = await UAuthClient.Identifiers.UpdateUserAsync(UserKey.Value, updateRequest); } if (result.IsSuccess) @@ -167,11 +167,11 @@ private async Task AddNewIdentifier() if (UserKey is null) { - result = await UAuthClient.Identifiers.AddSelfAsync(request); + result = await UAuthClient.Identifiers.AddMyAsync(request); } else { - result = await UAuthClient.Identifiers.AddAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.AddUserAsync(UserKey.Value, request); } if (result.IsSuccess) @@ -205,16 +205,16 @@ This will only mark the identifier as verified in UltimateAuth. return; } - VerifyUserIdentifierRequest request = new() { IdentifierId = id }; + VerifyUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.VerifySelfAsync(request); + result = await UAuthClient.Identifiers.VerifyMyAsync(request); } else { - result = await UAuthClient.Identifiers.VerifyAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.VerifyUserAsync(UserKey.Value, request); } if (result.IsSuccess) @@ -231,21 +231,21 @@ This will only mark the identifier as verified in UltimateAuth. private async Task SetPrimaryAsync(Guid id) { - SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + SetPrimaryUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + result = await UAuthClient.Identifiers.SetMyPrimaryAsync(request); } else { - result = await UAuthClient.Identifiers.SetPrimaryAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.SetUserPrimaryAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Primary identifier set successfully", Severity.Success); + Snackbar.Add("Primary identifier set successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } @@ -257,21 +257,21 @@ private async Task SetPrimaryAsync(Guid id) private async Task UnsetPrimaryAsync(Guid id) { - UnsetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + UnsetPrimaryUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.UnsetPrimarySelfAsync(request); + result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(request); } else { - result = await UAuthClient.Identifiers.UnsetPrimaryAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Primary identifier unset successfully", Severity.Success); + Snackbar.Add("Primary identifier unset successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } @@ -283,21 +283,21 @@ private async Task UnsetPrimaryAsync(Guid id) private async Task DeleteIdentifier(Guid id) { - DeleteUserIdentifierRequest request = new() { IdentifierId = id }; + DeleteUserIdentifierRequest request = new() { Id = id }; UAuthResult result; if (UserKey is null) { - result = await UAuthClient.Identifiers.DeleteSelfAsync(request); + result = await UAuthClient.Identifiers.DeleteMyAsync(request); } else { - result = await UAuthClient.Identifiers.DeleteAdminAsync(UserKey.Value, request); + result = await UAuthClient.Identifiers.DeleteUserAsync(UserKey.Value, request); } if (result.IsSuccess) { - Snackbar.Add("Identifier deleted successfully", Severity.Success); + Snackbar.Add("Identifier deleted successfully.", Severity.Success); await ReloadAsync(); StateHasChanged(); } 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 index 5fad228d..e676d6ed 100644 --- 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 @@ -63,12 +63,13 @@ 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 SetPermissionsRequest + var req = new SetRolePermissionsRequest { + RoleId = Role.Id, Permissions = permissions }; - var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + var result = await UAuthClient.Authorization.SetRolePermissionsAsync(req); if (!result.IsSuccess) { diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs index 2117a915..c0442702 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs @@ -40,7 +40,7 @@ protected override async Task OnInitializedAsync() } else { - result = await UAuthClient.Users.GetProfileAsync(UserKey.Value); + result = await UAuthClient.Users.GetUserAsync(UserKey.Value); } if (result.IsSuccess && result.Value is not null) @@ -98,7 +98,7 @@ private async Task SaveAsync() } else { - result = await UAuthClient.Users.UpdateProfileAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index 4f5fb818..977d1e38 100644 --- 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 @@ -20,7 +20,7 @@ public partial class ResetDialog private async Task RequestResetAsync() { - var request = new BeginCredentialResetRequest + var request = new BeginResetCredentialRequest { CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Code, 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 index 712e351a..22c6ea7d 100644 --- 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 @@ -65,10 +65,11 @@ private async Task CommittedItemChanges(RoleInfo role) { var req = new RenameRoleRequest { + Id = role.Id, Name = role.Name }; - var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, req); + var result = await UAuthClient.Authorization.RenameRoleAsync(req); if (result.IsSuccess) { @@ -121,8 +122,8 @@ private async Task DeleteRole(RoleId roleId) if (confirm != true) return; - var req = new DeleteRoleRequest(); - var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, req); + var req = new DeleteRoleRequest() { Id = roleId }; + var result = await UAuthClient.Authorization.DeleteRoleAsync(req); if (result.IsSuccess) { 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 index b6179488..1aaceacb 100644 --- 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 @@ -120,11 +120,11 @@ private async Task LogoutAllAsync() if (UserKey is null) { - result = await UAuthClient.Flows.LogoutAllDevicesSelfAsync(); + result = await UAuthClient.Flows.LogoutAllMyDevicesAsync(); } else { - result = await UAuthClient.Flows.LogoutAllDevicesAdminAsync(UserKey.Value); + result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(UserKey.Value); } if (result.IsSuccess) @@ -141,7 +141,7 @@ private async Task LogoutAllAsync() private async Task LogoutOthersAsync() { - var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); if (result.IsSuccess) { @@ -160,11 +160,11 @@ private async Task LogoutDeviceAsync(SessionChainId chainId) if (UserKey is null) { - result = await UAuthClient.Flows.LogoutDeviceSelfAsync(request); + result = await UAuthClient.Flows.LogoutMyDeviceAsync(request); } else { - result = await UAuthClient.Flows.LogoutDeviceAdminAsync(UserKey.Value, request); + result = await UAuthClient.Flows.LogoutUserDeviceAsync(UserKey.Value, request); } if (result.IsSuccess) 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 index 3a950e53..b8f511f8 100644 --- 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 @@ -44,8 +44,8 @@ Status @_user?.Status - - @foreach (var s in Enum.GetValues()) + + @foreach (var s in Enum.GetValues()) { @s } 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 index c917f086..5d3e3455 100644 --- 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 @@ -11,7 +11,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; public partial class UserDetailDialog { private UserView? _user; - private UserStatus _status; + private AdminAssignableUserStatus _status; [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; @@ -25,12 +25,12 @@ public partial class UserDetailDialog protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - var result = await UAuthClient.Users.GetProfileAsync(UserKey); + var result = await UAuthClient.Users.GetUserAsync(UserKey); if (result.IsSuccess) { _user = result.Value; - _status = _user?.Status ?? UserStatus.Unknown; + _status = _user?.Status.ToAdminAssignableUserStatus() ?? AdminAssignableUserStatus.Unknown; } } @@ -66,15 +66,15 @@ private async Task ChangeStatusAsync() ChangeUserStatusAdminRequest request = new() { - NewStatus = _status + NewStatus = _status, }; - var result = await UAuthClient.Users.ChangeStatusAdminAsync(_user.UserKey, request); + var result = await UAuthClient.Users.ChangeUserStatusAsync(_user.UserKey, request); if (result.IsSuccess) { Snackbar.Add("User status updated", Severity.Success); - _user = _user with { Status = _status }; + _user = _user with { Status = _status.ToUserStatus() }; } else { 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 index 349361a4..e89d60ff 100644 --- 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 @@ -49,7 +49,13 @@ private async Task AddRole() if (string.IsNullOrWhiteSpace(_selectedRole)) return; - var result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); + var request = new AssignRoleRequest + { + UserKey = UserKey, + RoleName = _selectedRole + }; + + var result = await UAuthClient.Authorization.AssignRoleToUserAsync(request); if (result.IsSuccess) { @@ -95,7 +101,13 @@ private async Task RemoveRole(string role) } } - var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); + var request = new RemoveRoleRequest + { + UserKey = UserKey, + RoleName = role + }; + + var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(request); if (result.IsSuccess) { 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 index 31aa7e68..a215a013 100644 --- 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 @@ -48,7 +48,7 @@ private async Task> LoadUsers(GridState state Descending = sort?.Descending ?? false }; - var res = await UAuthClient.Users.QueryUsersAsync(req); + var res = await UAuthClient.Users.QueryAsync(req); if (!res.IsSuccess || res.Value == null) { 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 index 9ee407b9..6c8122d8 100644 --- 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 @@ -205,8 +205,8 @@ private DialogParameters GetDialogParameters() private async Task SetAccountActiveAsync() { - ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.Active }; - var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.Active }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); if (result.IsSuccess) { diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs index ee8b4919..726c4864 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs @@ -25,7 +25,7 @@ private async Task ResetPasswordAsync() return; } - var request = new CompleteCredentialResetRequest + var request = new CompleteResetCredentialRequest { ResetToken = _code, NewSecret = _newPassword ?? string.Empty, diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index b427fb04..db00ab92 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface ISessionIssuer { - Task IssueSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + Task IssueSessionAsync(SessionIssuanceContext context, CancellationToken cancellationToken = default); Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs index 0a881359..f8c98d61 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs @@ -2,8 +2,8 @@ public enum ActionScope { - Anonymous, - Self, - Admin, - System + Anonymous = 0, + Self = 10, + Admin = 20, + System = 30 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs index 1671ed53..2e6bb373 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs @@ -2,11 +2,11 @@ public enum AuthOperation { - Login, - Access, - ResourceAccess, - Refresh, - Revoke, - Logout, - System + 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 index 5f329623..4634154a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs @@ -2,7 +2,7 @@ public enum AuthorizationDecision { - Allow, - Deny, - Challenge + Allow = 0, + Deny = 10, + Challenge = 20 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs index 46d8241a..1c1e11f2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs @@ -2,7 +2,7 @@ public enum DeviceMismatchBehavior { - Reject, - Allow, - AllowAndRebind + 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 index 08e57fe5..214d213a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs @@ -2,7 +2,7 @@ public enum CaseHandling { - Preserve, - ToLower, - ToUpper + 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 index 5063bda6..8e95af9e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs @@ -2,6 +2,6 @@ public enum DeleteMode { - Soft, - Hard + Soft = 0, + Hard = 10 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs index b236c0e5..1dc6aed7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public class PageRequest +public record PageRequest { public int PageNumber { get; init; } = 1; public int PageSize { get; init; } = 250; diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs index 9a9555bd..32da871e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs @@ -1,11 +1,11 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record ExternalLoginRequest { - public TenantKey Tenant { get; init; } - public string Provider { get; init; } = default!; - public string ExternalToken { get; init; } = default!; - public string? DeviceId { get; init; } + 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/LoginContinuationType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs index d8d953d3..33e3269f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs @@ -2,7 +2,7 @@ public enum LoginContinuationType { - Mfa, - Pkce, - External + Mfa = 0, + Pkce = 10, + External = 20 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs index 95a03a12..95802a57 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs @@ -2,7 +2,7 @@ public enum LoginStatus { - Success, - RequiresContinuation, - Failed + 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 index 5717252f..f5089e7a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs @@ -5,7 +5,6 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record ReauthRequest { - public TenantKey Tenant { get; init; } public AuthSessionId SessionId { get; init; } - public string Secret { get; init; } = default!; + public required string Secret { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs index 2395ccb4..3a386fc8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs @@ -2,6 +2,6 @@ public enum UAuthLoginType { - Password, // /auth/login - Pkce // /auth/pkce/complete + Password = 0, + Pkce = 10 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs index 5cc76251..052ef9f6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs @@ -1,12 +1,9 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class LogoutAllRequest +public sealed record LogoutAllRequest { - public TenantKey Tenant { get; init; } - /// /// The current session initiating the logout-all operation. /// Used to resolve the active chain when ExceptCurrent is true. @@ -17,6 +14,4 @@ public sealed class LogoutAllRequest /// If true, the current session will NOT be revoked. /// public bool ExceptCurrent { get; init; } - - public DateTimeOffset? At { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs index c53276d3..b8612931 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs @@ -2,9 +2,10 @@ public enum LogoutReason { - Explicit, - SessionExpired, - SecurityPolicy, - AdminForced, - TenantDisabled + 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 index 050a9b9d..9878f927 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs @@ -1,11 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record LogoutRequest { - public TenantKey Tenant { get; init; } public AuthSessionId SessionId { get; init; } - public DateTimeOffset? At { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs index 38f945b1..536ed568 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs @@ -2,5 +2,5 @@ public sealed record BeginMfaRequest { - public string MfaToken { get; init; } = default!; + public required string MfaToken { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs index abf719ff..7aa5d7f0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs @@ -2,6 +2,6 @@ public sealed record CompleteMfaRequest { - public string ChallengeId { get; init; } = default!; - public string Code { get; init; } = default!; + public required string ChallengeId { get; init; } + public required string Code { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs index f12ccedd..c0a4aad6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs @@ -2,6 +2,6 @@ public sealed record MfaChallengeResult { - public string ChallengeId { get; init; } = default!; - public string Method { get; init; } = default!; // totp, sms, email etc. + 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 index bcb1e132..39068268 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs @@ -6,8 +6,8 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record PkceAuthorizeCommand { - public string CodeChallenge { get; init; } = default!; - public string ChallengeMethod { get; init; } = "S256"; + public required string CodeChallenge { get; init; } + public required string ChallengeMethod { get; init; } = "S256"; public required DeviceContext Device { get; init; } public string? RedirectUri { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs index 152afcae..b9f80bfa 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs @@ -2,6 +2,6 @@ public sealed class PkceAuthorizeResponse { - public string AuthorizationCode { get; init; } = default!; + 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 index 22cb7bab..b951e1ec 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs @@ -2,21 +2,21 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class PkceCompleteRequest +public sealed record PkceCompleteRequest { [JsonPropertyName("authorization_code")] - public string AuthorizationCode { get; init; } = default!; + public required string AuthorizationCode { get; init; } [JsonPropertyName("code_verifier")] - public string CodeVerifier { get; init; } = default!; + public required string CodeVerifier { get; init; } - public string Identifier { get; init; } = default!; - public string Secret { get; init; } = default!; + public required string Identifier { get; init; } + public required string Secret { get; init; } [JsonPropertyName("return_url")] - public string ReturnUrl { get; init; } = default!; + public string ReturnUrl { get; init; } [JsonPropertyName("hub_session_id")] - public string HubSessionId { get; init; } = default!; + public string HubSessionId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs index fb0b7913..aef8a634 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class TryPkceLoginResult : IUAuthTryResult +public sealed record TryPkceLoginResult : IUAuthTryResult { public bool Success { get; init; } public AuthFailureReason? Reason { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs index a6120541..321cb061 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs @@ -2,11 +2,10 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class RefreshFlowRequest +public sealed record RefreshFlowRequest { public AuthSessionId? SessionId { get; init; } public string? RefreshToken { get; init; } public required DeviceContext Device { get; init; } - public DateTimeOffset Now { get; init; } public SessionTouchMode TouchMode { get; init; } = SessionTouchMode.IfNeeded; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs index 3c22c330..731248f6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs @@ -2,9 +2,9 @@ public enum RefreshStrategy { - NotSupported, - SessionOnly, // PureOpaque - TokenOnly, // PureJwt - TokenWithSessionCheck, // SemiHybrid - SessionAndToken // Hybrid + 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 index a9d308d0..a3cea858 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs @@ -6,12 +6,12 @@ public enum RefreshTokenPersistence /// Refresh token store'a yazılır. /// Login, first-issue gibi normal akışlar için. /// - Persist, + Persist = 0, /// /// Refresh token store'a yazılmaz. /// Rotation gibi özel akışlarda, /// caller tarafından kontrol edilir. /// - DoNotPersist + DoNotPersist = 10 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs index 01d25a1e..a7cda668 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record RefreshTokenValidationContext { public TenantKey Tenant { get; init; } - public string RefreshToken { get; init; } = default!; + public required string RefreshToken { get; init; } public DateTimeOffset Now { get; init; } public required DeviceContext Device { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs deleted file mode 100644 index e4d4f0cc..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotDto.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed class AuthSnapshotDto -{ - public IdentityDto? Identity { get; set; } - - public ClaimsDto? Claims { get; set; } -} 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/ClaimsDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs similarity index 89% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs index 76482332..005895b3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class ClaimsDto +public sealed class ClaimsInfo { public Dictionary Claims { get; set; } = new(); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs similarity index 86% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs index e962875e..4f008972 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionIdentityDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class IdentityDto +public sealed class IdentityInfo { public string Tenant { get; set; } = default!; diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs similarity index 60% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs index 353b8de5..c4519080 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs @@ -1,10 +1,10 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed class SessionValidationDto +public sealed class SessionValidationInfo { public int State { get; set; } = default!; public bool IsValid { get; set; } - public AuthSnapshotDto? Snapshot { get; set; } + public AuthSnapshotInfo? Snapshot { get; set; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs similarity index 95% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs index 070c1551..1b7886a7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; /// Represents the context in which a session is issued /// (login, refresh, reauthentication). /// -public sealed class AuthenticatedSessionContext +public sealed class SessionIssuanceContext { public TenantKey Tenant { get; init; } public required UserKey UserKey { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs deleted file mode 100644 index d15f1cfc..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed record SessionRefreshRequest -{ - public TenantKey Tenant { get; init; } - public string RefreshToken { get; init; } = default!; -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs index 820f19fd..ff3d61d6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs @@ -5,10 +5,10 @@ public enum SessionTouchMode /// /// Touch only if store policy allows (interval, throttling, etc.) /// - IfNeeded, + IfNeeded = 0, /// /// Always update session activity, ignoring store heuristics. /// - Force + Force = 10 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs index 1b13c185..182bd3e0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs @@ -10,7 +10,7 @@ public sealed record AuthTokens /// The issued access token. /// Always present when is returned. /// - public AccessToken AccessToken { get; init; } = default!; + public required AccessToken AccessToken { get; init; } public RefreshTokenInfo? RefreshToken { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs index 0ef2e9f6..06cd52af 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs @@ -2,6 +2,6 @@ public enum PrimaryTokenKind { - Session = 1, - AccessToken = 2 + Session = 0, + AccessToken = 10 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs index 3e17fc19..a50157ce 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs @@ -1,8 +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 = 1, - Jwt = 2 + Opaque = 0, + Jwt = 10 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs index 5e80df5d..3e2dfb49 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs @@ -2,14 +2,14 @@ public enum TokenInvalidReason { - Invalid, - Expired, - Revoked, - Malformed, - SignatureInvalid, - AudienceMismatch, - IssuerMismatch, - MissingSubject, - Unknown, - NotImplemented + 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/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs deleted file mode 100644 index d440a7e6..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Core.Contracts; - -public sealed record TokenIssueContext -{ - public TenantKey Tenant { get; init; } - public UAuthSession Session { get; init; } = default!; - public DateTimeOffset At { get; init; } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs index f822f02c..8bc8a155 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs @@ -16,5 +16,5 @@ public enum UserStatus PendingActivation = 60, PendingVerification = 70, - Unknown = 99 + Unknown = 100 } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs index f605ed94..0e0ab036 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; public static class SessionValidationMapper { - public static SessionValidationResult ToDomain(SessionValidationDto dto) + public static SessionValidationResult ToDomain(SessionValidationInfo dto) { var state = (SessionState)dto.State; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index 4c04143d..dd7b1e36 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -42,9 +42,7 @@ public async Task LogoutAsync(HttpContext ctx) { var request = new LogoutRequest { - Tenant = authFlow.Tenant, - SessionId = session.SessionId, - At = _clock.UtcNow, + SessionId = session.SessionId }; await _flow.LogoutAsync(request, ctx.RequestAborted); @@ -128,7 +126,7 @@ public async Task LogoutOthersAdminAsync(HttpContext ctx, UserKey userK if (!flow.IsAuthenticated) return Results.Unauthorized(); - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var access = await _accessContextFactory.CreateAsync( flow, diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index 1d3c3f3a..bc59d6b0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -44,10 +44,9 @@ public async Task RefreshAsync(HttpContext ctx) var request = new RefreshFlowRequest { - SessionId = flow?.Session?.SessionId, + SessionId = flow.Session?.SessionId, RefreshToken = _refreshTokenResolver.Resolve(ctx), - Device = flow!.Device, - Now = DateTimeOffset.UtcNow + Device = flow.Device, }; var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 18734ab8..54c018d5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -217,7 +217,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req var claims = await _claimsProvider.GetClaimsAsync(flow.Tenant, userKey.Value, ct); - var sessionContext = new AuthenticatedSessionContext + var sessionContext = new SessionIssuanceContext { Tenant = flow.Tenant, UserKey = userKey.Value, diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 66d5b8ee..38cf330d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -23,7 +23,7 @@ public UAuthSessionIssuer(ISessionStoreFactory storeFactory, IOpaqueTokenGenerat _options = options.Value; } - public async Task IssueSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) + public async Task IssueSessionAsync(SessionIssuanceContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority if (context.Mode == UAuthMode.PureJwt) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs index 8252f34c..b756516f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand +internal sealed record CreateLoginSessionCommand(SessionIssuanceContext LoginContext) : ISessionCommand { public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs index 5bfe197b..f5bab9db 100644 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs @@ -40,7 +40,7 @@ public async Task ValidateSessionAsync(SessionValidatio if (!response.IsSuccessStatusCode) return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); - var dto = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + var dto = await response.Content.ReadFromJsonAsync(cancellationToken: ct); if (dto is null) return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs index e3db3a89..249de06c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; @@ -11,15 +12,18 @@ 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) + IRefreshTokenRotationService tokenRotation, + IClock clock) { _sessionValidator = sessionValidator; _sessionRefresh = sessionRefresh; _tokenRotation = tokenRotation; + _clock = clock; } public async Task RefreshAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct = default) @@ -47,12 +51,14 @@ private async Task HandleSessionOnlyAsync(AuthFlowContext flo 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 = request.Now, + Now = now, Device = request.Device }, ct); @@ -65,7 +71,7 @@ private async Task HandleSessionOnlyAsync(AuthFlowContext flo TouchInterval = flow.EffectiveOptions.Options.Session.TouchInterval }; - var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, request.Now, ct); + var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); if (!refresh.IsSuccess || refresh.SessionId is null) return RefreshFlowResult.ReauthRequired(); @@ -78,12 +84,14 @@ private async Task HandleTokenOnlyAsync(AuthFlowContext flow, if (string.IsNullOrWhiteSpace(request.RefreshToken)) return RefreshFlowResult.ReauthRequired(); + var now = _clock.UtcNow; + var rotation = await _tokenRotation.RotateAsync( flow, new RefreshTokenRotationContext { RefreshToken = request.RefreshToken!, - Now = request.Now, + Now = now, Device = request.Device }, ct); @@ -91,24 +99,6 @@ private async Task HandleTokenOnlyAsync(AuthFlowContext flow, if (!rotation.Result.IsSuccess) return RefreshFlowResult.ReauthRequired(); - //if (rotation.Result.RefreshToken is not null) - //{ - // var converter = _userIdConverterResolver.GetConverter(); - - // await _refreshTokenStore.StoreAsync( - // flow.TenantId, - // new StoredRefreshToken - // { - // TokenHash = rotation.Result.RefreshToken.TokenHash, - // UserId = rotation.UserId!, - // SessionId = rotation.SessionId!.Value, - // ChainId = rotation.ChainId, - // ExpiresAt = rotation.Result.RefreshToken.ExpiresAt, - // IssuedAt = request.Now - // }, - // ct); - //} - return RefreshFlowResult.Success( outcome: RefreshOutcome.Rotated, accessToken: rotation.Result.AccessToken, @@ -120,12 +110,14 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re 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 = request.Now, + Now = now, Device = request.Device }, ct); @@ -138,7 +130,7 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re new RefreshTokenRotationContext { RefreshToken = request.RefreshToken!, - Now = request.Now, + Now = now, Device = request.Device, ExpectedSessionId = request.SessionId.Value }, @@ -152,7 +144,7 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re TouchInterval = flow.EffectiveOptions.Options.Session.TouchInterval }; - var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, request.Now, ct); + var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); if (!refresh.IsSuccess || refresh.SessionId is null) return RefreshFlowResult.ReauthRequired(); @@ -171,12 +163,14 @@ private async Task HandleSemiHybridAsync(AuthFlowContext flow 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 = request.Now, + Now = now, Device = request.Device }, ct); @@ -189,7 +183,7 @@ private async Task HandleSemiHybridAsync(AuthFlowContext flow new RefreshTokenRotationContext { RefreshToken = request.RefreshToken!, - Now = request.Now, + Now = now, Device = request.Device, ExpectedSessionId = request.SessionId.Value }, diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 23b7e174..95b91315 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +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; @@ -17,6 +18,7 @@ internal sealed class UAuthFlowService : IUAuthFlowService, IUAuthInternalFlowSe private readonly IInternalLoginOrchestrator _internalLoginOrchestrator; private readonly ISessionOrchestrator _orchestrator; private readonly UAuthEventDispatcher _events; + private readonly IClock _clock; public UAuthFlowService( IAuthFlowContextAccessor authFlow, @@ -24,7 +26,8 @@ public UAuthFlowService( ILoginOrchestrator loginOrchestrator, IInternalLoginOrchestrator internalLoginOrchestrator, ISessionOrchestrator orchestrator, - UAuthEventDispatcher events) + UAuthEventDispatcher events, + IClock clock) { _authFlow = authFlow; _authFlowContextFactory = authFlowContextFactory; @@ -32,6 +35,7 @@ public UAuthFlowService( _internalLoginOrchestrator = internalLoginOrchestrator; _orchestrator = orchestrator; _events = events; + _clock = clock; } public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) @@ -69,7 +73,7 @@ public async Task LoginAsync(AuthFlowContext flow, AuthExecutionCon public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) { var authFlow = _authFlow.Current; - var now = request.At ?? DateTimeOffset.UtcNow; + var now = _clock.UtcNow; var authContext = authFlow.ToAuthContext(now); var revoked = await _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.SessionId), ct); @@ -80,13 +84,13 @@ public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = defa if (authFlow.UserKey is not UserKey uaKey) return; - await _events.DispatchAsync(new UserLoggedOutContext(request.Tenant, uaKey, request.At ?? DateTimeOffset.Now, LogoutReason.Explicit, request.SessionId)); + 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 = request.At ?? DateTimeOffset.UtcNow; + var now = _clock.UtcNow; if (authFlow.Session is not SessionSecurityContext session) throw new InvalidOperationException("LogoutAll requires an active session."); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs index 6afebc1d..dd258fbb 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs @@ -1,6 +1,9 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; -public sealed class AssignRoleRequest +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record AssignRoleRequest { - public required string Role { get; init; } + 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 index 4a1958d1..1046ce61 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Authorization.Contracts; -public sealed class AuthorizationCheckRequest +public sealed record AuthorizationCheckRequest { public required string Action { get; init; } public string? Resource { get; init; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs index 539f7f86..e1edb5d8 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs @@ -1,7 +1,7 @@ namespace CodeBeam.UltimateAuth.Authorization.Contracts; -public sealed class CreateRoleRequest +public sealed record CreateRoleRequest { - public string Name { get; set; } = default!; - public IEnumerable? Permissions { get; set; } + 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 index cdc013f3..810be03f 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs @@ -2,7 +2,8 @@ namespace CodeBeam.UltimateAuth.Authorization.Contracts; -public sealed class DeleteRoleRequest +public sealed record DeleteRoleRequest { - public DeleteMode Mode { get; set; } + 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 index 4d33d28e..b30df8db 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs @@ -1,6 +1,7 @@ namespace CodeBeam.UltimateAuth.Authorization.Contracts; -public sealed class RenameRoleRequest +public sealed record RenameRoleRequest { - public string Name { get; set; } = default!; + public required RoleId Id { get; init; } + public required string Name { get; init; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RoleQuery.cs similarity index 81% rename from src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs rename to src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RoleQuery.cs index f9515966..e0262ebb 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RoleQuery.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Authorization.Contracts; -public sealed class RoleQuery : PageRequest +public sealed record RoleQuery : PageRequest { public string? Search { get; set; } public bool IncludeDeleted { get; set; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs deleted file mode 100644 index be5237b6..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; - -public sealed class SetPermissionsRequest -{ - public IEnumerable Permissions { get; set; } = []; -} 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.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index 172bad57..ae13fda7 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -126,7 +126,7 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - await _userRoles.AssignAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + await _userRoles.AssignAsync(accessContext, userKey, req.RoleName, ctx.RequestAborted); return Results.Ok(); } @@ -145,7 +145,7 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - await _userRoles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + await _userRoles.RemoveAsync(accessContext, userKey, req.RoleName, ctx.RequestAborted); return Results.Ok(); } @@ -217,7 +217,7 @@ public async Task SetRolePermissionsAsync(RoleId roleId, HttpContext ct if (!flow.IsAuthenticated) return Results.Unauthorized(); - var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UALoginDispatch.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor similarity index 100% rename from src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UALoginDispatch.razor rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs index ca9ec42b..78b6ffd9 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs @@ -9,12 +9,12 @@ public interface IAuthorizationClient Task> CheckAsync(AuthorizationCheckRequest request); Task> GetMyRolesAsync(PageRequest? request = null); Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null); - Task AssignRoleToUserAsync(UserKey userKey, string role); - Task RemoveRoleFromUserAsync(UserKey userKey, string role); + Task AssignRoleToUserAsync(AssignRoleRequest request); + Task RemoveRoleFromUserAsync(RemoveRoleRequest request); Task> CreateRoleAsync(CreateRoleRequest request); Task>> QueryRolesAsync(RoleQuery request); - Task RenameRoleAsync(RoleId roleId, RenameRoleRequest request); - Task SetPermissionsAsync(RoleId roleId, SetPermissionsRequest request); - Task> DeleteRoleAsync(RoleId roleId, DeleteRoleRequest 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 index 1bd06422..b9241513 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs @@ -9,13 +9,13 @@ public interface ICredentialClient Task> AddMyAsync(AddCredentialRequest request); Task> ChangeMyAsync(ChangeCredentialRequest request); Task RevokeMyAsync(RevokeCredentialRequest request); - Task> BeginResetMyAsync(BeginCredentialResetRequest request); - Task> CompleteResetMyAsync(CompleteCredentialResetRequest request); + Task> BeginResetMyAsync(BeginResetCredentialRequest request); + Task> CompleteResetMyAsync(CompleteResetCredentialRequest request); - Task> AddCredentialAsync(UserKey userKey, AddCredentialRequest request); - Task> ChangeCredentialAsync(UserKey userKey, ChangeCredentialRequest request); - Task RevokeCredentialAsync(UserKey userKey, RevokeCredentialRequest request); - Task> BeginResetCredentialAsync(UserKey userKey, BeginCredentialResetRequest request); - Task> CompleteResetCredentialAsync(UserKey userKey, CompleteCredentialResetRequest request); - Task DeleteCredentialAsync(UserKey userKey); + 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 index a97d3294..f2cda9e5 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -20,10 +20,10 @@ public interface IFlowClient Task TryCompletePkceLoginAsync(PkceCompleteRequest request, UAuthSubmitMode mode); Task CompletePkceLoginAsync(PkceCompleteRequest request); - Task> LogoutDeviceSelfAsync(LogoutDeviceRequest request); - Task LogoutOtherDevicesSelfAsync(); - Task LogoutAllDevicesSelfAsync(); - Task> LogoutDeviceAdminAsync(UserKey userKey, LogoutDeviceRequest request); - Task LogoutOtherDevicesAdminAsync(UserKey userKey, LogoutOtherDevicesAdminRequest request); - Task LogoutAllDevicesAdminAsync(UserKey userKey); + 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/IUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index c4f10ce0..3034f685 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -6,17 +6,17 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IUserClient { - Task>> QueryUsersAsync(UserQuery query); + Task>> QueryAsync(UserQuery query); Task> CreateAsync(CreateUserRequest request); - Task> CreateAdminAsync(CreateUserRequest request); - Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); - Task> ChangeStatusAdminAsync(UserKey userKey, ChangeUserStatusAdminRequest 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> GetProfileAsync(UserKey userKey); - Task UpdateProfileAsync(UserKey userKey, 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 index 9b1ee616..6ce76868 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs @@ -6,19 +6,19 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IUserIdentifierClient { - Task>> GetMyIdentifiersAsync(PageRequest? request = null); - Task AddSelfAsync(AddUserIdentifierRequest request); - Task UpdateSelfAsync(UpdateUserIdentifierRequest request); - Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); - Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request); - Task VerifySelfAsync(VerifyUserIdentifierRequest request); - Task DeleteSelfAsync(DeleteUserIdentifierRequest request); + 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>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null); - Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); - Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); - Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); - Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request); - Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request); - Task DeleteAdminAsync(UserKey userKey, 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 index 425a3f27..40484f0f 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -43,12 +43,9 @@ public async Task> GetUserRolesAsync(UserKey user return UAuthResultMapper.FromJson(raw); } - public async Task AssignRoleToUserAsync(UserKey userKey, string role) + public async Task AssignRoleToUserAsync(AssignRoleRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/assign"), new AssignRoleRequest - { - Role = role - }); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{request.UserKey.Value}/roles/assign"), request.RoleName); var result = UAuthResultMapper.From(raw); @@ -60,12 +57,9 @@ public async Task AssignRoleToUserAsync(UserKey userKey, string rol return result; } - public async Task RemoveRoleFromUserAsync(UserKey userKey, string role) + public async Task RemoveRoleFromUserAsync(RemoveRoleRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/remove"), new AssignRoleRequest - { - Role = role - }); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{request.UserKey.Value}/roles/remove"), request.RoleName); var result = UAuthResultMapper.From(raw); @@ -89,9 +83,9 @@ public async Task>> QueryRolesAsync(RoleQuery return UAuthResultMapper.FromJson>(raw); } - public async Task RenameRoleAsync(RoleId roleId, RenameRoleRequest request) + public async Task RenameRoleAsync(RenameRoleRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/rename"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{request.Id.Value}/rename"), request); var result = UAuthResultMapper.From(raw); if (result.IsSuccess) @@ -102,9 +96,9 @@ public async Task RenameRoleAsync(RoleId roleId, RenameRoleRequest return result; } - public async Task SetPermissionsAsync(RoleId roleId, SetPermissionsRequest request) + public async Task SetRolePermissionsAsync(SetRolePermissionsRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/permissions"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{request.RoleId.Value}/permissions"), request); var result = UAuthResultMapper.From(raw); if (result.IsSuccess) @@ -115,9 +109,9 @@ public async Task SetPermissionsAsync(RoleId roleId, SetPermissions return result; } - public async Task> DeleteRoleAsync(RoleId roleId, DeleteRoleRequest request) + public async Task> DeleteRoleAsync(DeleteRoleRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/delete"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{request.Id.Value}/delete"), request); var result = UAuthResultMapper.FromJson(raw); if (result.IsSuccess) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index ca73037a..1d02db68 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -49,13 +49,13 @@ public async Task RevokeMyAsync(RevokeCredentialRequest request) return UAuthResultMapper.From(raw); } - public async Task> BeginResetMyAsync(BeginCredentialResetRequest request) + 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(CompleteCredentialResetRequest request) + public async Task> CompleteResetMyAsync(CompleteResetCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/me/credentials/reset/complete"), request); if (raw.Ok) @@ -66,39 +66,39 @@ public async Task> CompleteResetMyAsync(Comp } - public async Task> AddCredentialAsync(UserKey userKey, AddCredentialRequest request) + public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/add"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/add"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeCredentialAsync(UserKey userKey, ChangeCredentialRequest request) + public async Task> ChangeUserAsync(UserKey userKey, ChangeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/change"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/change"), request); return UAuthResultMapper.FromJson(raw); } - public async Task RevokeCredentialAsync(UserKey userKey, RevokeCredentialRequest request) + public async Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/revoke"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/revoke"), request); return UAuthResultMapper.From(raw); } - public async Task> BeginResetCredentialAsync(UserKey userKey, BeginCredentialResetRequest request) + public async Task> BeginResetUserAsync(UserKey userKey, BeginResetCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/begin"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/reset/begin"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> CompleteResetCredentialAsync(UserKey userKey, CompleteCredentialResetRequest request) + public async Task> CompleteResetUserAsync(UserKey userKey, CompleteResetCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/complete"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/reset/complete"), request); return UAuthResultMapper.FromJson(raw); } - public async Task DeleteCredentialAsync(UserKey userKey) + public async Task DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/delete")); + 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 index 849e6fe0..988c0ab2 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -341,7 +341,7 @@ public async Task CompletePkceLoginAsync(PkceCompleteRequest request) await _post.NavigateAsync(url, payload); } - public async Task> LogoutDeviceSelfAsync(LogoutDeviceRequest request) + public async Task> LogoutMyDeviceAsync(LogoutDeviceRequest request) { var raw = await _post.SendJsonAsync(Url($"/me/logout-device"), request); @@ -360,25 +360,25 @@ public async Task> LogoutDeviceSelfAsync(LogoutDeviceR return UAuthResultMapper.FromJson(raw); } - public async Task> LogoutDeviceAdminAsync(UserKey userKey, LogoutDeviceRequest request) + 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 LogoutOtherDevicesSelfAsync() + public async Task LogoutMyOtherDevicesAsync() { var raw = await _post.SendJsonAsync(Url("/me/logout-others")); return UAuthResultMapper.From(raw); } - public async Task LogoutOtherDevicesAdminAsync(UserKey userKey, LogoutOtherDevicesAdminRequest request) + 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 LogoutAllDevicesSelfAsync() + public async Task LogoutAllMyDevicesAsync() { var raw = await _post.SendJsonAsync(Url("/me/logout-all")); if (raw.Ok) @@ -388,7 +388,7 @@ public async Task LogoutAllDevicesSelfAsync() return UAuthResultMapper.From(raw); } - public async Task LogoutAllDevicesAdminAsync(UserKey userKey) + public async Task LogoutAllUserDevicesAsync(UserKey userKey) { var raw = await _post.SendJsonAsync(Url($"/admin/users/{userKey.Value}/logout-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 index 38b7e610..001afe2e 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -49,7 +49,7 @@ public async Task DeleteMeAsync() return UAuthResultMapper.From(raw); } - public async Task>> QueryUsersAsync(UserQuery query) + public async Task>> QueryAsync(UserQuery query) { query ??= new UserQuery(); var raw = await _request.SendJsonAsync(Url("/admin/users/query"), query); @@ -62,13 +62,13 @@ public async Task> CreateAsync(CreateUserRequest r return UAuthResultMapper.FromJson(raw); } - public async Task> CreateAdminAsync(CreateUserRequest request) + public async Task> CreateAsAdminAsync(CreateUserRequest request) { var raw = await _request.SendJsonAsync(Url("/admin/users/create"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) + public async Task> ChangeMyStatusAsync(ChangeUserStatusSelfRequest request) { var raw = await _request.SendJsonAsync(Url("/me/status"), request); if (raw.Ok) @@ -78,7 +78,7 @@ public async Task> ChangeStatusSelfAsync(Cha return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeStatusAdminAsync(UserKey userKey, ChangeUserStatusAdminRequest request) + public async Task> ChangeUserStatusAsync(UserKey userKey, ChangeUserStatusAdminRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/status"), request); return UAuthResultMapper.FromJson(raw); @@ -90,13 +90,13 @@ public async Task> DeleteUserAsync(UserKey userKey return UAuthResultMapper.FromJson(raw); } - public async Task> GetProfileAsync(UserKey userKey) + 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 UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) + 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 index 15d6593f..d6313944 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -23,14 +23,14 @@ public UAuthUserIdentifierClient(IUAuthRequestClient request, IUAuthClientEvents private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task>> GetMyIdentifiersAsync(PageRequest? request = null) + 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 AddSelfAsync(AddUserIdentifierRequest request) + public async Task AddMyAsync(AddUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/add"), request); if (raw.Ok) @@ -40,7 +40,7 @@ public async Task AddSelfAsync(AddUserIdentifierRequest request) return UAuthResultMapper.From(raw); } - public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) + public async Task UpdateMyAsync(UpdateUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/update"), request); if (raw.Ok) @@ -50,7 +50,7 @@ public async Task UpdateSelfAsync(UpdateUserIdentifierRequest reque return UAuthResultMapper.From(raw); } - public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) + public async Task SetMyPrimaryAsync(SetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/set-primary"), request); if (raw.Ok) @@ -60,7 +60,7 @@ public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierReque return UAuthResultMapper.From(raw); } - public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) + public async Task UnsetMyPrimaryAsync(UnsetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/unset-primary"), request); if (raw.Ok) @@ -70,7 +70,7 @@ public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierR return UAuthResultMapper.From(raw); } - public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) + public async Task VerifyMyAsync(VerifyUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/verify"), request); if (raw.Ok) @@ -80,7 +80,7 @@ public async Task VerifySelfAsync(VerifyUserIdentifierRequest reque return UAuthResultMapper.From(raw); } - public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) + public async Task DeleteMyAsync(DeleteUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/delete"), request); if (raw.Ok) @@ -90,44 +90,44 @@ public async Task DeleteSelfAsync(DeleteUserIdentifierRequest reque return UAuthResultMapper.From(raw); } - public async Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null) + 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 AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) + public async Task AddUserAsync(UserKey userKey, AddUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/add"), request); return UAuthResultMapper.From(raw); } - public async Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request) + public async Task UpdateUserAsync(UserKey userKey, UpdateUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/update"), request); return UAuthResultMapper.From(raw); } - public async Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) + public async Task SetUserPrimaryAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/set-primary"), request); return UAuthResultMapper.From(raw); } - public async Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) + public async Task UnsetUserPrimaryAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/unset-primary"), request); return UAuthResultMapper.From(raw); } - public async Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request) + public async Task VerifyUserAsync(UserKey userKey, VerifyUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/verify"), request); return UAuthResultMapper.From(raw); } - public async Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request) + public async Task DeleteUserAsync(UserKey userKey, DeleteUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/delete"), request); return UAuthResultMapper.From(raw); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs index 2f6b46dd..fcb84bed 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialRequest() { - public CredentialType Type { get; set; } - public required string Secret { get; set; } - public string? Source { get; set; } + 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/BeginCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs deleted file mode 100644 index dd96bf98..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed record BeginCredentialResetRequest -{ - public string Identifier { get; init; } = default!; - public CredentialType CredentialType { get; set; } = CredentialType.Password; - public ResetCodeType ResetCodeType { get; set; } - public string? Channel { get; init; } - public TimeSpan? Validity { 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/CompleteCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs similarity index 64% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs index ec7abac5..bd49cb6d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs @@ -2,10 +2,10 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record CompleteCredentialResetRequest +public sealed record CompleteResetCredentialRequest { public string? Identifier { get; init; } - public CredentialType CredentialType { get; set; } = CredentialType.Password; + 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/CredentialActionRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs deleted file mode 100644 index d0f3465f..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed record CredentialActionRequest -{ - public Guid Id { get; set; } - public string? Reason { get; set; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs index b1bf31fa..1f808da5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs @@ -2,8 +2,8 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public class DeleteCredentialRequest +public sealed record DeleteCredentialRequest { public Guid Id { get; init; } - public DeleteMode Mode { get; set; } = DeleteMode.Soft; + 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 index 0f185532..710efa1a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs @@ -2,7 +2,7 @@ public sealed record ResetPasswordRequest { - public Guid Id { get; set; } + public Guid Id { get; init; } public required string NewPassword { get; init; } /// diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs deleted file mode 100644 index 855b606e..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed class RevokeAllCredentialsRequest -{ - public required UserKey UserKey { get; init; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs deleted file mode 100644 index a40a8345..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed record SetInitialCredentialRequest -{ - /// - /// Credential type to initialize (Password, Passkey, External, etc.). - /// - public required CredentialType Type { get; init; } - - /// - /// Plain secret (password, passkey public data, external token reference). - /// Will be hashed / processed by the credential service. - /// - public required string Secret { get; init; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs deleted file mode 100644 index 0268a697..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed record ValidateCredentialsRequest -{ - public string Identifier { get; init; } = default!; - public string Secret { get; init; } = default!; - - public CredentialType? CredentialType { get; init; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index c95daf58..cd214a18 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -92,7 +92,7 @@ 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 request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -109,7 +109,7 @@ 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 request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -216,7 +216,7 @@ 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 request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -233,7 +233,7 @@ public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext if (!TryGetSelf(out var flow, out var error)) return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index ad0a4ae0..71d051d6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -193,7 +193,7 @@ public async Task RevokeAsync(AccessContext context, Rev return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default) + public async Task BeginResetAsync(AccessContext context, BeginResetCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -249,7 +249,7 @@ public async Task BeginResetAsync(AccessContext cont return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task CompleteResetAsync(AccessContext context, CompleteCredentialResetRequest request, CancellationToken ct = default) + public async Task CompleteResetAsync(AccessContext context, CompleteResetCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs index 549659bf..4816c180 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs @@ -13,9 +13,9 @@ public interface ICredentialManagementService Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default); - Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default); + Task BeginResetAsync(AccessContext context, BeginResetCredentialRequest request, CancellationToken ct = default); - Task CompleteResetAsync(AccessContext context, CompleteCredentialResetRequest 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/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/SelfUserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfAssignableUserStatus.cs similarity index 72% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfAssignableUserStatus.cs index 752fc7eb..2ceed312 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfAssignableUserStatus.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public enum SelfUserStatus +public enum SelfAssignableUserStatus { Active = 0, SelfSuspended = 10, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs index f3a44e63..43e1fcf8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed class UserQuery : PageRequest +public sealed record UserQuery : PageRequest { public string? Search { get; set; } public UserStatus? Status { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs index b1b30e2b..2effff52 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs @@ -4,16 +4,66 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public static class UserStatusMapper { - public static UserStatus ToUserStatus(this SelfUserStatus selfStatus) + public static UserStatus ToUserStatus(this SelfAssignableUserStatus selfStatus) { switch (selfStatus) { - case SelfUserStatus.Active: + case SelfAssignableUserStatus.Active: return UserStatus.Active; - case SelfUserStatus.SelfSuspended: + 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/Requests/AddUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs index 0ae55149..745b40d3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs @@ -3,6 +3,6 @@ public sealed record AddUserIdentifierRequest { public UserIdentifierType Type { get; init; } - public string Value { get; init; } = default!; + public required string Value { get; init; } public bool IsPrimary { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs deleted file mode 100644 index c14eef0d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record BeginMfaSetupRequest -{ - public MfaMethod Method { get; init; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs index f3511af5..68e33657 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -1,8 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +namespace CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed class ChangeUserStatusAdminRequest +public sealed record ChangeUserStatusAdminRequest { - public required UserStatus NewStatus { get; init; } + 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 index f9c82d6e..f19968a2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public class ChangeUserStatusSelfRequest +public sealed record ChangeUserStatusSelfRequest { - public required SelfUserStatus NewStatus { get; init; } + public required SelfAssignableUserStatus NewStatus { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs deleted file mode 100644 index ad398643..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record CompleteMfaSetupRequest -{ - public MfaMethod Method { get; init; } - public string VerificationCode { get; init; } = default!; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs index 0ef75570..e63a67ba 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs @@ -4,6 +4,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record DeleteUserIdentifierRequest { - public Guid IdentifierId { get; set; } - public DeleteMode Mode { get; set; } = DeleteMode.Soft; + 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 index a6721e42..d416b9ee 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed class DeleteUserRequest +public sealed record DeleteUserRequest { - public DeleteMode Mode { get; init; } = DeleteMode.Soft; + public DeleteMode Mode { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs deleted file mode 100644 index 755a8de7..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record DisableMfaRequest -{ - public MfaMethod? Method { get; init; } // null = all -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs index c2d9ef05..72218125 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs @@ -1,7 +1,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed class IdentifierExistsRequest +public sealed record IdentifierExistsRequest { - public UserIdentifierType Type { get; set; } - public string Value { get; set; } = default!; + 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 index 4d744bd7..6ed81237 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceRequest.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed class LogoutDeviceRequest +public sealed record LogoutDeviceRequest { public required SessionChainId ChainId { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs deleted file mode 100644 index 7865c876..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed class LogoutOtherDevicesAdminRequest -{ - public required SessionChainId CurrentChainId { get; init; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs similarity index 76% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs index 6f6b4e97..ec7763df 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed class LogoutOtherDevicesSelfRequest +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 index 985e12e5..cbc4924f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; /// /// Request to register a new user with credentials. /// -public sealed class RegisterUserRequest +public sealed record RegisterUserRequest { /// /// Unique user identifier (username, email, or external id). diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs index 9cd43c8a..e396c161 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs @@ -2,5 +2,5 @@ public sealed record SetPrimaryUserIdentifierRequest { - public Guid IdentifierId { get; set; } + 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 index 7638a2cd..8e7675db 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs @@ -2,5 +2,5 @@ public sealed record UnsetPrimaryUserIdentifierRequest { - public Guid IdentifierId { get; set; } + public Guid Id { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs index f425570d..40233262 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs @@ -3,9 +3,9 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed class UserIdentifierQuery : PageRequest +public sealed record UserIdentifierQuery : PageRequest { - public UserKey? UserKey { get; set; } + public UserKey? UserKey { get; init; } - public bool IncludeDeleted { get; init; } = false; + 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 index 0e04c873..205c5460 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -2,5 +2,5 @@ public sealed record VerifyUserIdentifierRequest { - public Guid IdentifierId { get; init; } + public Guid Id { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs index b1dc66e0..d308ff45 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class UserLifecycleQuery : PageRequest +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 index 7b2808a9..3c3835e6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class UserProfileQuery : PageRequest +public sealed record UserProfileQuery : PageRequest { public bool IncludeDeleted { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index b122e142..aa354974 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -151,7 +151,7 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C var newStatus = request switch { ChangeUserStatusSelfRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), - ChangeUserStatusAdminRequest r => r.NewStatus, + ChangeUserStatusAdminRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), _ => throw new InvalidOperationException("invalid_request") }; @@ -323,7 +323,10 @@ public async Task> GetIdentifiersByUserAsync(Acc var targetUserKey = context.GetTargetUserKey(); query ??= new UserIdentifierQuery(); - query.UserKey = targetUserKey; + 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(); @@ -524,7 +527,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar EnsureOverrideAllowed(context); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var identifier = await identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); @@ -554,7 +557,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr EnsureOverrideAllowed(context); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var identifier = await identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); @@ -592,7 +595,7 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde EnsureOverrideAllowed(context); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var identifier = await identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); @@ -611,7 +614,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde EnsureOverrideAllowed(context); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var identifier = await identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index 431b8579..fced32ec 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -18,7 +18,7 @@ public async Task Begin_reset_with_token_should_return_token() var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); var result = await service.BeginResetAsync(context, - new BeginCredentialResetRequest + new BeginResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -39,7 +39,7 @@ public async Task Begin_reset_with_code_should_return_numeric_code() var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); var result = await service.BeginResetAsync(context, - new BeginCredentialResetRequest + new BeginResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -59,7 +59,7 @@ public async Task Begin_reset_for_unknown_user_should_not_fail() var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); var result = await service.BeginResetAsync(context, - new BeginCredentialResetRequest + new BeginResetCredentialRequest { Identifier = "unknown@test.com", CredentialType = CredentialType.Password, @@ -77,7 +77,7 @@ public async Task Reset_password_with_valid_token_should_succeed() var begin = await service.BeginResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginCredentialResetRequest + new BeginResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -86,7 +86,7 @@ public async Task Reset_password_with_valid_token_should_succeed() var result = await service.CompleteResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteCredentialResetRequest + new CompleteResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -105,7 +105,7 @@ public async Task Reset_password_with_same_password_should_fail() var begin = await service.BeginResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginCredentialResetRequest + new BeginResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -115,7 +115,7 @@ public async Task Reset_password_with_same_password_should_fail() Func act = async () => await service.CompleteResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteCredentialResetRequest + new CompleteResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -134,7 +134,7 @@ public async Task Reset_token_should_lock_after_max_attempts() var begin = await service.BeginResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginCredentialResetRequest + new BeginResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -147,7 +147,7 @@ public async Task Reset_token_should_lock_after_max_attempts() { await service.CompleteResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteCredentialResetRequest + new CompleteResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -161,7 +161,7 @@ await service.CompleteResetAsync( Func act = async () => await service.CompleteResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteCredentialResetRequest + new CompleteResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -180,7 +180,7 @@ public async Task Reset_token_should_be_single_use() var begin = await service.BeginResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginCredentialResetRequest + new BeginResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -189,7 +189,7 @@ public async Task Reset_token_should_be_single_use() await service.CompleteResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteCredentialResetRequest + new CompleteResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -200,7 +200,7 @@ await service.CompleteResetAsync( Func act = async () => await service.CompleteResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteCredentialResetRequest + new CompleteResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -220,7 +220,7 @@ public async Task Reset_token_should_fail_if_expired() var begin = await service.BeginResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginCredentialResetRequest + new BeginResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, @@ -232,7 +232,7 @@ public async Task Reset_token_should_fail_if_expired() Func act = async () => await service.CompleteResetAsync( TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteCredentialResetRequest + new CompleteResetCredentialRequest { Identifier = "admin", CredentialType = CredentialType.Password, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index 4dda4404..c09a2df2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -62,6 +62,16 @@ 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(); @@ -82,12 +92,12 @@ public Task> LogoutDeviceSelfAsync(LogoutDeviceRequest throw new NotImplementedException(); } - public Task LogoutOtherDevicesAdminAsync(LogoutOtherDevicesAdminRequest request) + public Task> LogoutMyDeviceAsync(LogoutDeviceRequest request) { throw new NotImplementedException(); } - public Task LogoutOtherDevicesAdminAsync(UserKey userKey, LogoutOtherDevicesAdminRequest request) + public Task LogoutMyOtherDevicesAsync() { throw new NotImplementedException(); } @@ -97,6 +107,16 @@ 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(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index 049eeefc..7122902a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -203,7 +203,7 @@ public async Task Unsetting_last_login_identifier_should_fail() await service.UnsetPrimaryUserIdentifierAsync(context, new UnsetPrimaryUserIdentifierRequest { - IdentifierId = email.Id + Id = email.Id }); await act.Should().ThrowAsync(); From 372da2638f3805767cadbfca7bc9ebd1082fc097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:14:30 +0300 Subject: [PATCH 43/50] Client Tests (#29) * Added Client Credentials Tests * Added Client Authorization Tests * Added Client Session Tests * Added Client User Tests * Added Client Flow Tests * Added Missing Flow Tests * Added Options & Runtime Tests * Added UAuthLoginForm Tests * Fix Test * UAuthApp & UAuthStateView Tests --- .../Components/UAuthStateView.razor.cs | 12 +- .../Services/UAuthAuthorizationClient.cs | 2 +- .../Services/UAuthSessionClient.cs | 16 +- .../Services/UAuthUserIdentifierClient.cs | 12 +- .../Bunit/UAuthAppTests.cs | 121 ++++ .../Bunit/UAuthLoginFormTests.cs | 214 +++++++ .../Bunit/UAuthStateViewTests.cs | 152 +++++ .../Client/ClientOptionsValidatorTests.cs | 17 + .../Client/ClientProductInfoTests.cs | 82 +++ .../Client/ClientProfileTests.cs | 86 +++ .../Client/UAuthClientAuthorizationTests.cs | 264 +++++++++ .../Client/UAuthClientCredentialsTests.cs | 168 ++++++ .../Client/UAuthClientSessionTests.cs | 264 +++++++++ .../Client/UAuthClientUserIdentifiersTests.cs | 210 +++++++ .../Client/UAuthClientUserTests.cs | 208 +++++++ .../Client/UAuthFlowClientTests.cs | 525 +++++++++++++++++- .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 1 + .../Helpers/TestAuthState.cs | 83 +++ .../Helpers/UAuthClientTestBase.cs | 164 ++++++ 19 files changed, 2583 insertions(+), 18 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthAppTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthLoginFormTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientProductInfoTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientProfileTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientSessionTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserIdentifiersTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/UAuthClientTestBase.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs index 753f75e9..e488b329 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs @@ -98,10 +98,18 @@ private async Task EvaluateAuthorizationAsync() var results = new List(); if (roles.Count > 0) - results.Add(roles.Any(AuthState.IsInRole)); + { + results.Add(MatchAll + ? roles.All(AuthState.IsInRole) + : roles.Any(AuthState.IsInRole)); + } if (permissions.Count > 0) - results.Add(permissions.Any(AuthState.HasPermission)); + { + results.Add(MatchAll + ? permissions.All(AuthState.HasPermission) + : permissions.Any(AuthState.HasPermission)); + } if (!string.IsNullOrWhiteSpace(Policy)) results.Add(await EvaluatePolicyAsync()); diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 40484f0f..822af1ae 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -39,7 +39,7 @@ public async Task> GetMyRolesAsync(PageRequest? r public async Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null) { request ??= new PageRequest(); - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/get"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey.Value}/roles/get"), request); return UAuthResultMapper.FromJson(raw); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs index 90a38e5b..80e5ce01 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -33,13 +33,13 @@ public async Task>> GetMyChainsAsyn public async Task> GetMyChainDetailAsync(SessionChainId chainId) { - var raw = await _request.SendFormAsync(Url($"/me/sessions/chains/{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}/revoke")); + var raw = await _request.SendJsonAsync(Url($"/me/sessions/chains/{chainId.Value}/revoke")); var result = UAuthResultMapper.FromJson(raw); if (result.Value?.CurrentChain == true) @@ -73,37 +73,37 @@ public async Task RevokeAllMyChainsAsync() public async Task>> GetUserChainsAsync(UserKey userKey, PageRequest? request = null) { request ??= new PageRequest(); - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/sessions/chains"), request); + 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}/sessions/chains/{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}/sessions/{sessionId}/revoke")); + 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}/sessions/chains/{chainId}/revoke")); + 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}/sessions/revoke-root")); + 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}/sessions/revoke-all")); + 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/UAuthUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index d6313944..fa240378 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -99,37 +99,37 @@ public async Task>> GetUserAsync(Use public async Task AddUserAsync(UserKey userKey, AddUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/identifiers/add"), 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}/identifiers/update"), 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}/identifiers/set-primary"), 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}/identifiers/unset-primary"), 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}/identifiers/verify"), 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}/identifiers/delete"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/delete"), request); return UAuthResultMapper.From(raw); } } 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/ClientOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs index cb09ff7b..c897c7ae 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs @@ -62,4 +62,21 @@ public void Valid_client_options_should_pass() 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/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/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 index 21f57769..13bc71f7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -9,6 +9,8 @@ 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; @@ -16,7 +18,7 @@ namespace CodeBeam.UltimateAuth.Tests.Unit; -public class UAuthFlowClientTests +public class UAuthFlowClientTests : UAuthClientTestBase { private readonly Mock _mockRequest = new(); @@ -292,4 +294,525 @@ public async Task Validate_Should_Return_Result() 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/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 465c2fc0..ee936bd7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -8,6 +8,7 @@ + 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/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 From d37fe488e9aa6738a194918befcdce4db9c3dcc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:11:22 +0300 Subject: [PATCH 44/50] Update README with current development status and roadmap Removed outdated development status and updated roadmap release dates. --- README.md | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 39ee5ee2..3245ea7a 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,5 @@ ![UltimateAuth Banner](https://github.com/user-attachments/assets/4204666e-b57a-4cb5-8846-dc7e4f16bfe9) -⚠️ **UltimateAuth is under active development.** - -The core architecture and public APIs are now implemented and validated through the sample application. - -We are currently polishing the developer experience, reviewing the public client API surface, and preparing the EF Core integration packages. - -The first preview release (**v 0.1.0-preview**) is planned within the next week. - - ![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) @@ -37,9 +28,9 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t | Phase | Version | Scope | Status | Release Date | | ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | -| First Preview | 0.1.0-preview | "Stable" Preview Core | ✅ Completed | Last check | -| 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 | +| 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 | @@ -71,7 +62,8 @@ We keep it up-to-date with current priorities, planned features, and progress. F --- -## 🌟 Why UltimateAuth: The Six-Point Principles +## 🌟 Why UltimateAuth +The Six-Point Principles ### 1) Unified Authentication System @@ -230,7 +222,7 @@ Add this in `_Imports.razor` --- -## Usage +## 💡 Usage Inject IUAuthClient and simply call methods. From e1feef174a90420e072ba7d56ba0c43ebda10b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:12:58 +0300 Subject: [PATCH 45/50] Docs Content (#30) * Docs Content * Refactor and enhance getting started documentation * Add quick start guide for UltimateAuth setup Added a quick start guide for setting up UltimateAuth with Blazor Server, including project creation, package installation, service configuration, middleware setup, and first login example. * Enhance Real-World Setup documentation for UltimateAuth Updated the Real-World Setup guide to clarify the use of a persistent configuration and added detailed setup instructions for UltimateAuth with Entity Framework Core and Blazor. * Improve Service Collection Extensions * Add authentication model documentation for UltimateAuth Added comprehensive documentation on the authentication model for UltimateAuth, detailing the concepts of Root, Chain, and Session, and their interactions. * Move fundaments to content * Add flow-based authentication documentation Introduced a comprehensive guide on flow-based authentication, detailing its principles, processes, and benefits compared to traditional token-based systems. * Document authentication modes in UltimateAuth Added documentation for authentication modes in UltimateAuth, detailing available modes, comparisons, and recommendations. * Add fundamentals section to documentation * Add documentation for Client Profiles in UltimateAuth Added detailed documentation on Client Profiles, including definitions, runtime detection, configuration options, and built-in profiles. * Add runtime architecture documentation for UltimateAuth This document outlines the runtime architecture of UltimateAuth, detailing the structured execution pipeline for authentication, including components like Endpoint Filter, AuthFlowContext, Flow Service, Orchestrator, and Authority. * Enhance getting started guide with UAuthHub config Added UAuthHub Pipeline Configuration section to the setup guide. * Add request lifecycle documentation for UltimateAuth This document explains the request lifecycle in UltimateAuth, detailing the processing of passive and active flow requests, middleware pipeline, and user resolution. * Document Flow Execution Boundary for authentication Add section on Flow Execution Boundary to clarify authentication flow behavior. * Fix Tenant Restriction even in normal Requests * Add documentation for Auth Flows in UltimateAuth Introduced a comprehensive guide on authentication flows in UltimateAuth, detailing the flow-driven system, types of flows, and supporting concepts. * Add detailed documentation for login flow in UltimateAuth This document outlines the login flow in UltimateAuth, detailing the structured pipeline from identifier resolution to session creation and optional token issuance. It emphasizes the importance of session hierarchy and security considerations. * Add documentation for UltimateAuth refresh flow Document the refresh flow in UltimateAuth, detailing its strategies and execution steps. * Add documentation for logout flow in UltimateAuth Document the logout flow and its distinctions in UltimateAuth, including session, device, and identity scopes. * Add session lifecycle documentation for UltimateAuth Document the structured session lifecycle in UltimateAuth, detailing core entities, relationships, and lifecycle phases. * Add documentation for token behavior in UltimateAuth Document the behavior and characteristics of tokens in UltimateAuth, including types, modes, access and refresh tokens, security model, and key takeaways. * Add device management documentation Document the device management features in UltimateAuth, covering the importance of devices, their lifecycle, security model, and configuration options. * Add configuration and extensibility documentation Added detailed documentation on configuration and extensibility in UltimateAuth, covering configuration layers, sources, and safety measures. * Create configuration overview documentation Added comprehensive overview of UltimateAuth configuration, detailing its runtime-adaptive nature and layered configuration model. * Some Improvements * Document server options for UltimateAuth configuration Added documentation for configuring server options in UltimateAuth, including usage examples and key takeaways. * Add client options documentation for UltimateAuth Added documentation for client options in UltimateAuth, detailing configuration, usage, and key features. * Add documentation for configuration sources and rules Document configuration sources, precedence rules, and best practices for UltimateAuth. * Add advanced configuration documentation for UltimateAuth Added detailed documentation on advanced configuration options for UltimateAuth, including customization points, service replacement, and safety boundaries. * Add documentation for Plugin Domains in UltimateAuth Introduced documentation for Plugin Domains in UltimateAuth, detailing architecture, extensibility, and recommended approaches. * Add Users Domain documentation Document the Users Domain in UltimateAuth, detailing core concepts, lifecycle, identifiers, and user profiles. * Add documentation for Credentials Domain Document the credentials domain, including core concepts, types, validation, integration with users, lifecycle, and security behavior. * Enhance documentation on plugin domains Added sections on domain isolation and communication via hooks. * Clean up formatting in plugin-domains index Remove unnecessary line break in documentation. * Add documentation for Authorization & Policies domain Added detailed documentation for the Authorization & Policies domain, covering core concepts, permission structure, built-in action catalog, role definitions, permission resolution, claims integration, authorization flow, and policies. * Create policies.md * Add client usage guide for UltimateAuth Added a comprehensive client usage guide for UltimateAuth, detailing its features, architecture, core concepts, examples, and state events. * Add authentication guide for UltimateAuth client Added comprehensive authentication guide for UltimateAuth client, covering login, refresh, logout, and PKCE flow. * Document Client Entry Point for UltimateAuth Added section on Client Entry Point with usage examples. * Update example for LoginAsync method * Improve clarity on authentication mode usage Clarified usage recommendations for authentication modes. * Add session management guide for UltimateAuth client Added comprehensive session management guide for UltimateAuth client, detailing session structure, methods for retrieving active sessions, logging out, revoking sessions, and security implications. * Add User Identifiers Guide documentation This document provides a comprehensive guide on user identifiers in UltimateAuth, detailing their types, management, and security considerations. * Add User Management Guide for UltimateAuth client This document provides a comprehensive guide on user management using the UltimateAuth client, covering user operations, profile management, lifecycle, and admin functionalities. * Add Authorization Guide for UltimateAuth client This guide details the management of roles, permissions, and access control using the UltimateAuth client, including core concepts, querying roles, creating and renaming roles, setting permissions, and user role assignments. * Add Credential Management Guide This document provides a comprehensive guide on managing user credentials with the UltimateAuth client, covering operations like changing, resetting, adding, and revoking credentials, along with security notes and summaries. * Add session security model documentation Document the hierarchical session security model of UltimateAuth, detailing the roles of Root, Chain, and Session in authentication, as well as security versioning, validation, device awareness, expiration, and revocation boundaries. * Document refresh token rotation security features Added comprehensive documentation on refresh token rotation, detailing its security features, rotation model, and invalid scenarios. * Add documentation for access token behavior in UltimateAuth Document the behavior and principles of access tokens in UltimateAuth, including token types, mode-dependent behavior, lifetime strategy, refresh interaction, claims model, and security implications. * Add detailed policy pipeline documentation This document provides a comprehensive overview of the multi-stage policy pipeline used in UltimateAuth for authorization decisions, detailing each step from context enrichment to final decision-making. * Create readme.md --- docs/.gitkeep | 1 - docs/content/auth-flows/device-management.md | 159 +++++++++++++ docs/content/auth-flows/index.md | 136 +++++++++++ docs/content/auth-flows/login-flow.md | 156 ++++++++++++ docs/content/auth-flows/logout-flow.md | 174 ++++++++++++++ docs/content/auth-flows/refresh-flow.md | 174 ++++++++++++++ docs/content/auth-flows/session-lifecycle.md | 194 +++++++++++++++ docs/content/auth-flows/token-behavior.md | 162 +++++++++++++ docs/content/client/authentication.md | 180 ++++++++++++++ docs/content/client/authorization.md | 129 ++++++++++ docs/content/client/credentials.md | 167 +++++++++++++ docs/content/client/identifiers.md | 178 ++++++++++++++ docs/content/client/index.md | 111 +++++++++ docs/content/client/session-management.md | 164 +++++++++++++ docs/content/client/user-management.md | 175 ++++++++++++++ .../configuration/advanced-configuration.md | 170 +++++++++++++ docs/content/configuration/client-options.md | 118 +++++++++ .../configuration/configuration-overview.md | 166 +++++++++++++ .../configuration/configuration-sources.md | 159 +++++++++++++ docs/content/configuration/index.md | 107 +++++++++ docs/content/configuration/server-options.md | 207 ++++++++++++++++ docs/content/fundamentals/auth-model.md | 182 ++++++++++++++ docs/content/fundamentals/auth-modes.md | 160 +++++++++++++ docs/content/fundamentals/client-profiles.md | 144 +++++++++++ docs/content/fundamentals/flow-based-auth.md | 198 ++++++++++++++++ docs/content/fundamentals/index.md | 12 + .../content/fundamentals/request-lifecycle.md | 156 ++++++++++++ .../fundamentals/runtime-architecture.md | 158 +++++++++++++ docs/content/getting-started/index.md | 118 +++++++++ docs/content/getting-started/quickstart.md | 100 ++++++++ .../getting-started/real-world-setup.md | 126 ++++++++++ .../plugin-domains/authorization-domain.md | 181 ++++++++++++++ .../plugin-domains/credential-domain.md | 115 +++++++++ docs/content/plugin-domains/index.md | 146 ++++++++++++ docs/content/plugin-domains/policies.md | 127 ++++++++++ docs/content/plugin-domains/users-domain.md | 114 +++++++++ docs/content/readme.md | 67 ++++++ .../content/security/access-token-behavior.md | 165 +++++++++++++ docs/content/security/policy-pipeline.md | 223 ++++++++++++++++++ docs/content/security/refresh-rotation.md | 163 +++++++++++++ .../security/session-security-model.md | 214 +++++++++++++++++ .../Program.cs | 2 - .../Contracts/Token/TokenType.cs | 6 +- .../Domain/Session/SessionRefreshStatus.cs | 8 +- .../Domain/Session/SessionState.cs | 16 +- .../MultiTenancy/UAuthTenantContext.cs | 1 + .../Options/TokenResponseMode.cs | 8 +- .../Options/UAuthClientProfile.cs | 14 +- .../Contracts/RefreshTokenStatus.cs | 11 - .../Middlewares/TenantMiddleware.cs | 13 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + 51 files changed, 6088 insertions(+), 49 deletions(-) delete mode 100644 docs/.gitkeep create mode 100644 docs/content/auth-flows/device-management.md create mode 100644 docs/content/auth-flows/index.md create mode 100644 docs/content/auth-flows/login-flow.md create mode 100644 docs/content/auth-flows/logout-flow.md create mode 100644 docs/content/auth-flows/refresh-flow.md create mode 100644 docs/content/auth-flows/session-lifecycle.md create mode 100644 docs/content/auth-flows/token-behavior.md create mode 100644 docs/content/client/authentication.md create mode 100644 docs/content/client/authorization.md create mode 100644 docs/content/client/credentials.md create mode 100644 docs/content/client/identifiers.md create mode 100644 docs/content/client/index.md create mode 100644 docs/content/client/session-management.md create mode 100644 docs/content/client/user-management.md create mode 100644 docs/content/configuration/advanced-configuration.md create mode 100644 docs/content/configuration/client-options.md create mode 100644 docs/content/configuration/configuration-overview.md create mode 100644 docs/content/configuration/configuration-sources.md create mode 100644 docs/content/configuration/index.md create mode 100644 docs/content/configuration/server-options.md create mode 100644 docs/content/fundamentals/auth-model.md create mode 100644 docs/content/fundamentals/auth-modes.md create mode 100644 docs/content/fundamentals/client-profiles.md create mode 100644 docs/content/fundamentals/flow-based-auth.md create mode 100644 docs/content/fundamentals/index.md create mode 100644 docs/content/fundamentals/request-lifecycle.md create mode 100644 docs/content/fundamentals/runtime-architecture.md create mode 100644 docs/content/getting-started/index.md create mode 100644 docs/content/getting-started/quickstart.md create mode 100644 docs/content/getting-started/real-world-setup.md create mode 100644 docs/content/plugin-domains/authorization-domain.md create mode 100644 docs/content/plugin-domains/credential-domain.md create mode 100644 docs/content/plugin-domains/index.md create mode 100644 docs/content/plugin-domains/policies.md create mode 100644 docs/content/plugin-domains/users-domain.md create mode 100644 docs/content/readme.md create mode 100644 docs/content/security/access-token-behavior.md create mode 100644 docs/content/security/policy-pipeline.md create mode 100644 docs/content/security/refresh-rotation.md create mode 100644 docs/content/security/session-security-model.md delete mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs 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/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index 6e346489..a8841e99 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -1,6 +1,5 @@ using CodeBeam.UltimateAuth.Client.Blazor.Extensions; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm; using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure; using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi; @@ -22,7 +21,6 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); -builder.Services.AddUltimateAuth(); builder.Services.AddUltimateAuthClientBlazor(o => { o.Endpoints.BasePath = "https://localhost:6110/auth"; // UAuthHub URL diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs index 1c26c007..da231a01 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs @@ -2,7 +2,7 @@ public enum TokenType { - Opaque, - Jwt, - Unknown + Opaque = 0, + Jwt = 10, + Unknown = 100 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs index 1d4927fa..16f23a04 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs @@ -2,8 +2,8 @@ public enum SessionRefreshStatus { - Success, - ReauthRequired, - InvalidRequest, - Failed + Success = 0, + ReauthRequired = 10, + InvalidRequest = 20, + Failed = 30 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs index 112c33e8..26dccd7c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs @@ -6,12 +6,12 @@ /// public enum SessionState { - Active, - Expired, - Revoked, - NotFound, - Invalid, - SecurityMismatch, - DeviceMismatch, - Unsupported + Active = 0, + Expired = 10, + Revoked = 20, + NotFound = 30, + Invalid = 40, + SecurityMismatch = 50, + DeviceMismatch = 60, + Unsupported = 100 } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs index 017c36f9..9f73a2c9 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs @@ -20,5 +20,6 @@ private UAuthTenantContext(TenantKey tenant) 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/TokenResponseMode.cs b/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs index 5d5ded63..65437c18 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs @@ -2,8 +2,8 @@ public enum TokenResponseMode { - None, - Cookie, - Header, - Body + 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 index c5bf2c3c..310a0be2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs @@ -2,11 +2,11 @@ public enum UAuthClientProfile { - NotSpecified, - BlazorWasm, - BlazorServer, - Maui, - WebServer, - Api, - UAuthHub = 1000 + NotSpecified = 0, + BlazorWasm = 10, + BlazorServer = 20, + Maui = 30, + WebServer = 40, + Api = 50, + UAuthHub = 100 } diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs deleted file mode 100644 index 0accae3c..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/RefreshTokenStatus.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Contracts; - -public enum RefreshTokenStatus -{ - Valid = 0, - Expired = 1, - Revoked = 2, - NotFound = 3, - Reused = 4, - SessionMismatch = 5 -} diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index 719f90b1..6c2a97cf 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -30,17 +30,12 @@ public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOp 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) { - //if (opts.RequireTenant) - //{ - // context.Response.StatusCode = StatusCodes.Status400BadRequest; - // await context.Response.WriteAsync("Tenant is required."); - // return; - //} - - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Tenant could not be resolved."); + context.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.Unresolved(); + await _next(context); return; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 7cc9dac0..42431a3b 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ 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; @@ -57,6 +58,7 @@ public static IServiceCollection AddUltimateAuthClient(this IServiceCollection s { ArgumentNullException.ThrowIfNull(services); + services.AddUltimateAuth(); services.TryAddSingleton(); services.AddOptions() From 1d11c35f900dde946917ef27280664b3462927c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:33:06 +0300 Subject: [PATCH 46/50] Website Docs (#32) * Website Docs * Enhance Home Page --- UltimateAuth.slnx | 4 + .../Brand/UAuthLogo.razor | 27 + .../Brand/UAuthLogo.razor.cs | 46 + .../Brand/UAuthLogoVariant.cs | 7 + ...eBeam.UltimateAuth.Docs.Wasm.Client.csproj | 18 + .../Layout/MainLayout.razor | 55 + .../Layout/MainLayout.razor.cs | 39 + .../Layout/MainLayout.razor.css | 20 + .../Pages/Home.razor | 145 + .../Pages/NotFound.razor | 5 + .../Program.cs | 10 + .../Properties/launchSettings.json | 12 + .../Routes.razor | 6 + .../_Imports.razor | 13 + .../wwwroot/appsettings.Development.json | 8 + .../wwwroot/appsettings.json | 8 + .../CodeBeam.UltimateAuth.Docs.Wasm.csproj | 15 + .../Components/App.razor | 29 + .../Components/Pages/Error.razor | 36 + .../Components/_Imports.razor | 11 + .../Program.cs | 35 + .../Properties/launchSettings.json | 25 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../wwwroot/UltimateAuth-final-01.png | Bin 0 -> 3670 bytes .../wwwroot/app.css | 200 + .../wwwroot/favicon.png | Bin 0 -> 1148 bytes .../lib/bootstrap/dist/css/bootstrap-grid.css | 4085 ++++++ .../bootstrap/dist/css/bootstrap-grid.css.map | 1 + .../bootstrap/dist/css/bootstrap-grid.min.css | 6 + .../dist/css/bootstrap-grid.min.css.map | 1 + .../bootstrap/dist/css/bootstrap-grid.rtl.css | 4084 ++++++ .../dist/css/bootstrap-grid.rtl.css.map | 1 + .../dist/css/bootstrap-grid.rtl.min.css | 6 + .../dist/css/bootstrap-grid.rtl.min.css.map | 1 + .../bootstrap/dist/css/bootstrap-reboot.css | 597 + .../dist/css/bootstrap-reboot.css.map | 1 + .../dist/css/bootstrap-reboot.min.css | 6 + .../dist/css/bootstrap-reboot.min.css.map | 1 + .../dist/css/bootstrap-reboot.rtl.css | 594 + .../dist/css/bootstrap-reboot.rtl.css.map | 1 + .../dist/css/bootstrap-reboot.rtl.min.css | 6 + .../dist/css/bootstrap-reboot.rtl.min.css.map | 1 + .../dist/css/bootstrap-utilities.css | 5402 +++++++ .../dist/css/bootstrap-utilities.css.map | 1 + .../dist/css/bootstrap-utilities.min.css | 6 + .../dist/css/bootstrap-utilities.min.css.map | 1 + .../dist/css/bootstrap-utilities.rtl.css | 5393 +++++++ .../dist/css/bootstrap-utilities.rtl.css.map | 1 + .../dist/css/bootstrap-utilities.rtl.min.css | 6 + .../css/bootstrap-utilities.rtl.min.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.css | 12057 ++++++++++++++++ .../lib/bootstrap/dist/css/bootstrap.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.min.css | 6 + .../bootstrap/dist/css/bootstrap.min.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.rtl.css | 12030 +++++++++++++++ .../bootstrap/dist/css/bootstrap.rtl.css.map | 1 + .../bootstrap/dist/css/bootstrap.rtl.min.css | 6 + .../dist/css/bootstrap.rtl.min.css.map | 1 + .../lib/bootstrap/dist/js/bootstrap.bundle.js | 6314 ++++++++ .../bootstrap/dist/js/bootstrap.bundle.js.map | 1 + .../bootstrap/dist/js/bootstrap.bundle.min.js | 7 + .../dist/js/bootstrap.bundle.min.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.esm.js | 4447 ++++++ .../bootstrap/dist/js/bootstrap.esm.js.map | 1 + .../bootstrap/dist/js/bootstrap.esm.min.js | 7 + .../dist/js/bootstrap.esm.min.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.js | 4494 ++++++ .../lib/bootstrap/dist/js/bootstrap.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.min.js | 7 + .../bootstrap/dist/js/bootstrap.min.js.map | 1 + 71 files changed, 60379 insertions(+) create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogo.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogo.razor.cs create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogoVariant.cs create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/CodeBeam.UltimateAuth.Docs.Wasm.Client.csproj create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor.cs create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Home.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/NotFound.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Program.cs create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Properties/launchSettings.json create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Routes.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/_Imports.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/appsettings.Development.json create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/appsettings.json create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.csproj create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/App.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/Pages/Error.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/_Imports.razor create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Program.cs create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Properties/launchSettings.json create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/appsettings.Development.json create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/appsettings.json create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/UltimateAuth-final-01.png create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/app.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/favicon.png create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.js create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js create mode 100644 docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index cba5a257..4d101bde 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,4 +1,8 @@ + + + + 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 0000000000000000000000000000000000000000..da686529e1dfcaa0e96fbb1ecf7806a082d4cf71 GIT binary patch literal 3670 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 Z2vZ^)90V(5QWr&l9^6p7LeuW~zW`we&8PqX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ * { + 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":"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EE,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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nEV;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;IAO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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;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AAA;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/l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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;I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","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":"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","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":"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EE,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;EA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;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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nEV;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;IAO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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;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AAA;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/l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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;I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","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":"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","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-`